@pi-archimedes/subagent 2.0.1 → 2.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/README.md +15 -9
- package/package.json +2 -2
- package/src/agent-manager.ts +45 -24
- package/src/agents.test.ts +37 -0
- package/src/agents.ts +7 -2
- package/src/compact.test.ts +437 -0
- package/src/cost.test.ts +62 -0
- package/src/frontmatter-io.test.ts +119 -0
- package/src/local-config.test.ts +96 -0
- package/src/local-config.ts +50 -6
- package/src/save-agent.test.ts +317 -1
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
import { buildActivityLine, renderCompactSingle, renderCompactParallel } from "./compact.js";
|
|
4
|
+
import type { SubagentResult, SubagentProgress, SubagentDetails, SubagentToolCall } from "./types.js";
|
|
5
|
+
|
|
6
|
+
// ── Mocks ───────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
vi.mock("@earendil-works/pi-tui", () => {
|
|
9
|
+
class MockTextInner {
|
|
10
|
+
private _content = "";
|
|
11
|
+
|
|
12
|
+
setText(content: string): void {
|
|
13
|
+
this._content = content;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getContent(): string {
|
|
17
|
+
return this._content;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
Text: MockTextInner,
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Re-import MockText for use in tests (same class as the mock)
|
|
26
|
+
import { Text as MockText } from "@earendil-works/pi-tui";
|
|
27
|
+
|
|
28
|
+
// Type-safe helper: ActivityData with all optional fields for test convenience
|
|
29
|
+
// ActivityData is defined in compact.ts — reconstruct the shape here
|
|
30
|
+
type ActivityData = {
|
|
31
|
+
currentTool: string | undefined;
|
|
32
|
+
currentToolArgs: string | undefined;
|
|
33
|
+
currentToolStartedAt: number | undefined;
|
|
34
|
+
finalOutput: string | undefined;
|
|
35
|
+
status: "running" | "completed" | "failed" | undefined;
|
|
36
|
+
error: string | undefined;
|
|
37
|
+
toolCalls?: (SubagentToolCall | string)[] | undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function activityData(p: Partial<ActivityData>): ActivityData {
|
|
41
|
+
return {
|
|
42
|
+
currentTool: undefined,
|
|
43
|
+
currentToolArgs: undefined,
|
|
44
|
+
currentToolStartedAt: undefined,
|
|
45
|
+
finalOutput: undefined,
|
|
46
|
+
status: undefined,
|
|
47
|
+
error: undefined,
|
|
48
|
+
toolCalls: undefined,
|
|
49
|
+
...p,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Mock theme that tracks calls for verification
|
|
54
|
+
function makeMockTheme() {
|
|
55
|
+
const calls: Array<{ fn: string; args: unknown[] }> = [];
|
|
56
|
+
|
|
57
|
+
const theme = {
|
|
58
|
+
fg: (token: string, text: string) => {
|
|
59
|
+
calls.push({ fn: "fg", args: [token, text] });
|
|
60
|
+
return `[${token}]${text}[/${token}]`;
|
|
61
|
+
},
|
|
62
|
+
bold: (text: string) => {
|
|
63
|
+
calls.push({ fn: "bold", args: [text] });
|
|
64
|
+
return `**${text}**`;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return { theme, calls };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── buildActivityLine ──────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe("buildActivityLine", () => {
|
|
74
|
+
it("shows error prefix with truncated error when error is present", () => {
|
|
75
|
+
const { theme } = makeMockTheme();
|
|
76
|
+
const result = buildActivityLine(
|
|
77
|
+
activityData({ error: "Something went wrong", status: "running" }),
|
|
78
|
+
theme,
|
|
79
|
+
);
|
|
80
|
+
expect(result).toContain("✗ Something went wrong");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("shows '✓ Done' when status is completed", () => {
|
|
84
|
+
const { theme } = makeMockTheme();
|
|
85
|
+
const result = buildActivityLine(
|
|
86
|
+
activityData({ status: "completed" }),
|
|
87
|
+
theme,
|
|
88
|
+
);
|
|
89
|
+
expect(result).toContain("✓ Done");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("shows '✗ Failed' when status is failed", () => {
|
|
93
|
+
const { theme } = makeMockTheme();
|
|
94
|
+
const result = buildActivityLine(
|
|
95
|
+
activityData({ status: "failed" }),
|
|
96
|
+
theme,
|
|
97
|
+
);
|
|
98
|
+
expect(result).toContain("✗ Failed");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("shows tool name + args when running with current tool", () => {
|
|
102
|
+
const { theme } = makeMockTheme();
|
|
103
|
+
const result = buildActivityLine(
|
|
104
|
+
activityData({
|
|
105
|
+
status: "running",
|
|
106
|
+
currentTool: "read",
|
|
107
|
+
currentToolArgs: '{"path": "foo.ts"}',
|
|
108
|
+
}),
|
|
109
|
+
theme,
|
|
110
|
+
);
|
|
111
|
+
expect(result).toContain("read");
|
|
112
|
+
expect(result).toContain("foo.ts");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("shows last tool call when running with no current tool but toolCalls present", () => {
|
|
116
|
+
const { theme } = makeMockTheme();
|
|
117
|
+
const result = buildActivityLine(
|
|
118
|
+
activityData({
|
|
119
|
+
status: "running",
|
|
120
|
+
toolCalls: [
|
|
121
|
+
{ name: "bash", argsPreview: "ls -la", error: false },
|
|
122
|
+
],
|
|
123
|
+
}),
|
|
124
|
+
theme,
|
|
125
|
+
);
|
|
126
|
+
expect(result).toContain("bash");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("shows '↳ Starting...' when running with no info", () => {
|
|
130
|
+
const { theme } = makeMockTheme();
|
|
131
|
+
const result = buildActivityLine(
|
|
132
|
+
activityData({ status: "running" }),
|
|
133
|
+
theme,
|
|
134
|
+
);
|
|
135
|
+
expect(result).toContain("↳ Starting...");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("shows first line of final output when available", () => {
|
|
139
|
+
const { theme } = makeMockTheme();
|
|
140
|
+
const result = buildActivityLine(
|
|
141
|
+
activityData({
|
|
142
|
+
status: "running",
|
|
143
|
+
finalOutput: "first line of output\nsecond line\nthird line",
|
|
144
|
+
}),
|
|
145
|
+
theme,
|
|
146
|
+
);
|
|
147
|
+
expect(result).toContain("first line of output");
|
|
148
|
+
expect(result).not.toContain("second line");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns empty string when status is undefined and no data", () => {
|
|
152
|
+
const { theme } = makeMockTheme();
|
|
153
|
+
const result = buildActivityLine(activityData({}), theme);
|
|
154
|
+
expect(result).toBe("");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("error takes priority over completed status", () => {
|
|
158
|
+
const { theme } = makeMockTheme();
|
|
159
|
+
const result = buildActivityLine(
|
|
160
|
+
activityData({ error: "oops", status: "completed" }),
|
|
161
|
+
theme,
|
|
162
|
+
);
|
|
163
|
+
expect(result).toContain("✗ oops");
|
|
164
|
+
expect(result).not.toContain("✓ Done");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("completed status takes priority over currentTool", () => {
|
|
168
|
+
const { theme } = makeMockTheme();
|
|
169
|
+
const result = buildActivityLine(
|
|
170
|
+
activityData({ status: "completed", currentTool: "read" }),
|
|
171
|
+
theme,
|
|
172
|
+
);
|
|
173
|
+
expect(result).toContain("✓ Done");
|
|
174
|
+
expect(result).not.toContain("read");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("shows string toolCall in toolCalls array", () => {
|
|
178
|
+
const { theme } = makeMockTheme();
|
|
179
|
+
const result = buildActivityLine(
|
|
180
|
+
activityData({
|
|
181
|
+
status: "running",
|
|
182
|
+
toolCalls: ["some-string-call"],
|
|
183
|
+
}),
|
|
184
|
+
theme,
|
|
185
|
+
);
|
|
186
|
+
expect(result).toContain("some-string-call");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("colors last tool call name with error color when error flag set", () => {
|
|
190
|
+
const { theme, calls } = makeMockTheme();
|
|
191
|
+
buildActivityLine(
|
|
192
|
+
activityData({
|
|
193
|
+
status: "running",
|
|
194
|
+
toolCalls: [{ name: "bash", argsPreview: "rm -rf /", error: true }],
|
|
195
|
+
}),
|
|
196
|
+
theme,
|
|
197
|
+
);
|
|
198
|
+
// The tool name "bash" should be colored with "error" token
|
|
199
|
+
const fgCalls = calls.filter(c => c.fn === "fg");
|
|
200
|
+
const errorCall = fgCalls.find(c => c.args[0] === "error" && c.args[1] === "bash");
|
|
201
|
+
expect(errorCall).toBeDefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("colors last tool call name with success color when no error", () => {
|
|
205
|
+
const { theme, calls } = makeMockTheme();
|
|
206
|
+
buildActivityLine(
|
|
207
|
+
activityData({
|
|
208
|
+
status: "running",
|
|
209
|
+
toolCalls: [{ name: "read", argsPreview: "file.ts", error: false }],
|
|
210
|
+
}),
|
|
211
|
+
theme,
|
|
212
|
+
);
|
|
213
|
+
const fgCalls = calls.filter(c => c.fn === "fg");
|
|
214
|
+
const successCall = fgCalls.find(c => c.args[0] === "success" && c.args[1] === "read");
|
|
215
|
+
expect(successCall).toBeDefined();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ── renderCompactSingle ────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
describe("renderCompactSingle", () => {
|
|
222
|
+
function makeResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
|
|
223
|
+
return {
|
|
224
|
+
agent: "test-agent",
|
|
225
|
+
task: "do something",
|
|
226
|
+
exitCode: 0,
|
|
227
|
+
usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: 0.01, turns: 3 },
|
|
228
|
+
model: "gpt-4",
|
|
229
|
+
finalOutput: undefined,
|
|
230
|
+
error: undefined,
|
|
231
|
+
progress: undefined,
|
|
232
|
+
progressSummary: { toolCount: 5, tokens: 150, durationMs: 3000 },
|
|
233
|
+
...overrides,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
it("calls setText with expected output structure for completed agent", () => {
|
|
238
|
+
const { theme } = makeMockTheme();
|
|
239
|
+
const text = new MockText();
|
|
240
|
+
const result = makeResult({ exitCode: 0 });
|
|
241
|
+
const context = { state: {}, invalidate: vi.fn() };
|
|
242
|
+
|
|
243
|
+
renderCompactSingle(text, result, undefined, theme, context);
|
|
244
|
+
|
|
245
|
+
const output = (text as unknown as { getContent(): string }).getContent();
|
|
246
|
+
expect(output).toContain("test-agent");
|
|
247
|
+
expect(output).toContain("do something");
|
|
248
|
+
expect(output).toContain("✓ Done");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("calls setText with expected output structure for running agent", () => {
|
|
252
|
+
const { theme } = makeMockTheme();
|
|
253
|
+
const text = new MockText();
|
|
254
|
+
const result = makeResult({ exitCode: 1 });
|
|
255
|
+
const progress: SubagentProgress = {
|
|
256
|
+
agent: "test-agent",
|
|
257
|
+
status: "running",
|
|
258
|
+
task: "do something",
|
|
259
|
+
currentTool: "read",
|
|
260
|
+
currentToolArgs: undefined,
|
|
261
|
+
currentToolStartedAt: undefined,
|
|
262
|
+
toolCount: 2,
|
|
263
|
+
inputTokens: 50,
|
|
264
|
+
outputTokens: 30,
|
|
265
|
+
tokens: 80,
|
|
266
|
+
cost: 0.005,
|
|
267
|
+
durationMs: 1000,
|
|
268
|
+
error: undefined,
|
|
269
|
+
model: "gpt-4",
|
|
270
|
+
output: undefined,
|
|
271
|
+
recentOutput: undefined,
|
|
272
|
+
toolCalls: undefined,
|
|
273
|
+
};
|
|
274
|
+
const context = { state: {}, invalidate: vi.fn() };
|
|
275
|
+
|
|
276
|
+
renderCompactSingle(text, result, progress, theme, context);
|
|
277
|
+
|
|
278
|
+
const output = (text as unknown as { getContent(): string }).getContent();
|
|
279
|
+
expect(output).toContain("test-agent");
|
|
280
|
+
expect(output).toContain("read");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("returns the text instance", () => {
|
|
284
|
+
const { theme } = makeMockTheme();
|
|
285
|
+
const text = new MockText();
|
|
286
|
+
const result = makeResult();
|
|
287
|
+
const context = { state: {}, invalidate: vi.fn() };
|
|
288
|
+
|
|
289
|
+
const returned = renderCompactSingle(text, result, undefined, theme, context);
|
|
290
|
+
expect(returned).toBe(text);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── renderCompactParallel ──────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
describe("renderCompactParallel", () => {
|
|
297
|
+
function makeDetails(overrides: Partial<SubagentDetails> = {}): SubagentDetails {
|
|
298
|
+
return {
|
|
299
|
+
mode: "parallel",
|
|
300
|
+
results: [
|
|
301
|
+
{
|
|
302
|
+
agent: "agent-a",
|
|
303
|
+
task: "task a",
|
|
304
|
+
exitCode: 0,
|
|
305
|
+
usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
|
|
306
|
+
model: undefined,
|
|
307
|
+
finalOutput: undefined,
|
|
308
|
+
error: undefined,
|
|
309
|
+
progress: undefined,
|
|
310
|
+
progressSummary: { toolCount: 0, tokens: 0, durationMs: 0 },
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
agent: "agent-b",
|
|
314
|
+
task: "task b",
|
|
315
|
+
exitCode: 1,
|
|
316
|
+
usage: { input: 200, output: 100, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
|
|
317
|
+
model: undefined,
|
|
318
|
+
finalOutput: undefined,
|
|
319
|
+
error: "failed",
|
|
320
|
+
progress: undefined,
|
|
321
|
+
progressSummary: { toolCount: 0, tokens: 0, durationMs: 0 },
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
progress: undefined,
|
|
325
|
+
...overrides,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
it("calls setText with one line per result", () => {
|
|
330
|
+
const { theme } = makeMockTheme();
|
|
331
|
+
const text = new MockText();
|
|
332
|
+
const details = makeDetails();
|
|
333
|
+
const context = { state: {}, invalidate: vi.fn() };
|
|
334
|
+
|
|
335
|
+
renderCompactParallel(text, details, theme, context);
|
|
336
|
+
|
|
337
|
+
const output = (text as unknown as { getContent(): string }).getContent();
|
|
338
|
+
expect(output).toContain("agent-a");
|
|
339
|
+
expect(output).toContain("agent-b");
|
|
340
|
+
// Two results joined by newline
|
|
341
|
+
const lines = output.split("\n");
|
|
342
|
+
expect(lines.length).toBeGreaterThanOrEqual(2);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("returns the text instance", () => {
|
|
346
|
+
const { theme } = makeMockTheme();
|
|
347
|
+
const text = new MockText();
|
|
348
|
+
const details = makeDetails();
|
|
349
|
+
const context = { state: {}, invalidate: vi.fn() };
|
|
350
|
+
|
|
351
|
+
const returned = renderCompactParallel(text, details, theme, context);
|
|
352
|
+
expect(returned).toBe(text);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ── Property tests (fast-check) ────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
describe("buildActivityLine property tests", () => {
|
|
359
|
+
it("truncated error never exceeds truncLine limit of 80 chars", () => {
|
|
360
|
+
fc.assert(
|
|
361
|
+
fc.property(
|
|
362
|
+
fc.string({ minLength: 1, maxLength: 500 }),
|
|
363
|
+
(error) => {
|
|
364
|
+
const { theme } = makeMockTheme();
|
|
365
|
+
const result = buildActivityLine(
|
|
366
|
+
activityData({ error, status: "running" }),
|
|
367
|
+
theme,
|
|
368
|
+
);
|
|
369
|
+
// Strip the "✗ " prefix and ANSI tags to get the actual truncated text
|
|
370
|
+
const withoutPrefix = result.replace(/^\[error\]✗ /, "");
|
|
371
|
+
// The visible text (without closing ANSI tag) should be <= 80
|
|
372
|
+
const visibleText = withoutPrefix.replace(/\[\/error\]$/, "");
|
|
373
|
+
expect(visibleText.length).toBeLessThanOrEqual(80);
|
|
374
|
+
},
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("truncated argsPreview never exceeds truncLine limit of 60 chars", () => {
|
|
380
|
+
fc.assert(
|
|
381
|
+
fc.property(
|
|
382
|
+
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => !s.includes(": ") && !s.includes("\n") && !s.includes("|")),
|
|
383
|
+
fc.string({ minLength: 1, maxLength: 500 }).filter((s) => !s.includes(": ") && !s.includes("\n") && !s.includes("|")),
|
|
384
|
+
(currentTool, currentToolArgs) => {
|
|
385
|
+
const { theme } = makeMockTheme();
|
|
386
|
+
const result = buildActivityLine(
|
|
387
|
+
activityData({ status: "running", currentTool, currentToolArgs }),
|
|
388
|
+
theme,
|
|
389
|
+
);
|
|
390
|
+
// Extract the args portion after ": "
|
|
391
|
+
const argsMatch = result.match(/: (.+?)(?:\s\|.*|\[\/dim\].*)?$/);
|
|
392
|
+
expect(argsMatch).not.toBeNull();
|
|
393
|
+
const argsText = argsMatch![1]!.replace(/\[\/dim\]$/, "").replace(/\[dim\]/, "");
|
|
394
|
+
expect(argsText.length).toBeLessThanOrEqual(60);
|
|
395
|
+
},
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("truncated finalOutput first line never exceeds truncLine limit of 80 chars", () => {
|
|
401
|
+
fc.assert(
|
|
402
|
+
fc.property(
|
|
403
|
+
fc.string({ minLength: 1, maxLength: 500 }),
|
|
404
|
+
(finalOutput) => {
|
|
405
|
+
const { theme } = makeMockTheme();
|
|
406
|
+
const result = buildActivityLine(
|
|
407
|
+
activityData({ status: "running", finalOutput }),
|
|
408
|
+
theme,
|
|
409
|
+
);
|
|
410
|
+
// Strip ANSI tags to get visible text
|
|
411
|
+
const visible = result.replace(/\[muted\]/g, "").replace(/\[\/muted\]/g, "");
|
|
412
|
+
// The visible text after "↳ " should be <= 80
|
|
413
|
+
const afterArrow = visible.replace(/^↳ /, "");
|
|
414
|
+
expect(afterArrow.length).toBeLessThanOrEqual(80);
|
|
415
|
+
},
|
|
416
|
+
),
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("truncated string toolCall never exceeds truncLine limit of 60 chars", () => {
|
|
421
|
+
fc.assert(
|
|
422
|
+
fc.property(
|
|
423
|
+
fc.string({ minLength: 1, maxLength: 500 }),
|
|
424
|
+
(toolCallStr) => {
|
|
425
|
+
const { theme } = makeMockTheme();
|
|
426
|
+
const result = buildActivityLine(
|
|
427
|
+
activityData({ status: "running", toolCalls: [toolCallStr] }),
|
|
428
|
+
theme,
|
|
429
|
+
);
|
|
430
|
+
const visible = result.replace(/\[dim\]/g, "").replace(/\[\/dim\]/g, "");
|
|
431
|
+
const afterArrow = visible.replace(/^↳ /, "");
|
|
432
|
+
expect(afterArrow.length).toBeLessThanOrEqual(60);
|
|
433
|
+
},
|
|
434
|
+
),
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
});
|
package/src/cost.test.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { Events } from "@pi-archimedes/core/bus";
|
|
3
|
+
|
|
4
|
+
// ── mock bus ────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
let mockEmit: ReturnType<typeof vi.fn>;
|
|
7
|
+
|
|
8
|
+
vi.mock("@pi-archimedes/core/bus", async (importOriginal) => {
|
|
9
|
+
const actual = await importOriginal() as typeof import("@pi-archimedes/core/bus");
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
getBus: () => ({
|
|
13
|
+
emit: mockEmit,
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const { emitCostUpdate } = await import("./cost.js");
|
|
19
|
+
|
|
20
|
+
describe("emitCostUpdate", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockEmit = vi.fn();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("emits COST_UPDATE event with correct source prefix", () => {
|
|
26
|
+
emitCostUpdate("my-agent", { inputTokens: 100, outputTokens: 50 });
|
|
27
|
+
|
|
28
|
+
expect(mockEmit).toHaveBeenCalledWith(Events.COST_UPDATE, {
|
|
29
|
+
source: "subagent:my-agent",
|
|
30
|
+
inputTokens: 100,
|
|
31
|
+
outputTokens: 50,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("passes through all usage fields", () => {
|
|
36
|
+
emitCostUpdate("test-agent", {
|
|
37
|
+
inputTokens: 1000,
|
|
38
|
+
outputTokens: 500,
|
|
39
|
+
cacheReadTokens: 200,
|
|
40
|
+
cacheWriteTokens: 100,
|
|
41
|
+
cost: 0.05,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(mockEmit).toHaveBeenCalledWith(Events.COST_UPDATE, {
|
|
45
|
+
source: "subagent:test-agent",
|
|
46
|
+
inputTokens: 1000,
|
|
47
|
+
outputTokens: 500,
|
|
48
|
+
cacheReadTokens: 200,
|
|
49
|
+
cacheWriteTokens: 100,
|
|
50
|
+
cost: 0.05,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("works with partial usage fields", () => {
|
|
55
|
+
emitCostUpdate("minimal", { cost: 0.01 });
|
|
56
|
+
|
|
57
|
+
expect(mockEmit).toHaveBeenCalledWith(Events.COST_UPDATE, {
|
|
58
|
+
source: "subagent:minimal",
|
|
59
|
+
cost: 0.01,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
import { validateAgentName, serializeAgent, AGENT_NAME_REGEX } from "./frontmatter-io.js";
|
|
4
|
+
import type { AgentConfig } from "./agents.js";
|
|
5
|
+
|
|
6
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|
9
|
+
return {
|
|
10
|
+
name: "test-agent",
|
|
11
|
+
description: "A test agent",
|
|
12
|
+
systemPrompt: "You are a helpful test agent.",
|
|
13
|
+
source: "user",
|
|
14
|
+
filePath: "/tmp/test-agent.md",
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── validateAgentName ───────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
describe("validateAgentName", () => {
|
|
22
|
+
it("accepts valid names (3-50 chars, lowercase alnum + hyphens)", () => {
|
|
23
|
+
expect(validateAgentName("abc")).toBeNull();
|
|
24
|
+
expect(validateAgentName("test-agent")).toBeNull();
|
|
25
|
+
expect(validateAgentName("a1b2c3")).toBeNull();
|
|
26
|
+
expect(validateAgentName("my-long-agent-name-with-many-hyphens")).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("accepts single char names", () => {
|
|
30
|
+
expect(validateAgentName("a")).toBeNull();
|
|
31
|
+
expect(validateAgentName("z")).toBeNull();
|
|
32
|
+
expect(validateAgentName("0")).toBeNull();
|
|
33
|
+
expect(validateAgentName("9")).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("rejects empty string", () => {
|
|
37
|
+
expect(validateAgentName("")).toBe("Name is required");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects uppercase", () => {
|
|
41
|
+
expect(validateAgentName("Abc")).not.toBeNull();
|
|
42
|
+
expect(validateAgentName("ABC")).not.toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects special chars", () => {
|
|
46
|
+
expect(validateAgentName("test_agent")).not.toBeNull();
|
|
47
|
+
expect(validateAgentName("test.agent")).not.toBeNull();
|
|
48
|
+
expect(validateAgentName("test agent")).not.toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects names starting/ending with hyphens", () => {
|
|
52
|
+
expect(validateAgentName("-test")).not.toBeNull();
|
|
53
|
+
expect(validateAgentName("test-")).not.toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects two-char names (not single char, not 3+)", () => {
|
|
57
|
+
expect(validateAgentName("ab")).not.toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── serializeAgent ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe("serializeAgent", () => {
|
|
64
|
+
it("produces valid YAML frontmatter", () => {
|
|
65
|
+
const agent = makeAgent();
|
|
66
|
+
const output = serializeAgent(agent);
|
|
67
|
+
expect(output).toMatch(/^---\n/);
|
|
68
|
+
expect(output).toContain("name: test-agent");
|
|
69
|
+
expect(output).toContain("description: A test agent");
|
|
70
|
+
expect(output).toContain("You are a helpful test agent.");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("quotes values that need quoting", () => {
|
|
74
|
+
const agent = makeAgent({ description: "value with: colon" });
|
|
75
|
+
const output = serializeAgent(agent);
|
|
76
|
+
expect(output).toContain('description: "value with: colon"');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("includes optional fields when present", () => {
|
|
80
|
+
const agent = makeAgent({
|
|
81
|
+
tools: ["read", "write"],
|
|
82
|
+
model: "gpt-4o",
|
|
83
|
+
thinking: "high",
|
|
84
|
+
});
|
|
85
|
+
const output = serializeAgent(agent);
|
|
86
|
+
expect(output).toContain("tools: read, write");
|
|
87
|
+
expect(output).toContain("model: gpt-4o");
|
|
88
|
+
expect(output).toContain("thinking: high");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("omits optional fields when absent", () => {
|
|
92
|
+
const agent = makeAgent();
|
|
93
|
+
const output = serializeAgent(agent);
|
|
94
|
+
expect(output).not.toContain("tools:");
|
|
95
|
+
expect(output).not.toContain("model:");
|
|
96
|
+
expect(output).not.toContain("thinking:");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("property: output starts with '---\\n' and contains closing '\\n---\\n' delimiter", () => {
|
|
100
|
+
fc.assert(
|
|
101
|
+
fc.property(
|
|
102
|
+
fc.string({ minLength: 1, maxLength: 50 }),
|
|
103
|
+
fc.string({ minLength: 1, maxLength: 100 }),
|
|
104
|
+
fc.string({ minLength: 1, maxLength: 200 }),
|
|
105
|
+
(name, desc, prompt) => {
|
|
106
|
+
const agent = makeAgent({ name, description: desc, systemPrompt: prompt });
|
|
107
|
+
const output = serializeAgent(agent);
|
|
108
|
+
if (!output.startsWith("---\n")) {
|
|
109
|
+
throw new Error(`Output does not start with '---\\n': ${JSON.stringify(output.slice(0, 20))}`);
|
|
110
|
+
}
|
|
111
|
+
if (!output.includes("\n---\n")) {
|
|
112
|
+
throw new Error(`Output does not contain closing '\\n---\\n' delimiter`);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
),
|
|
116
|
+
{ verbose: false },
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|