@aaroncql/pim-agent 0.0.1

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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,359 @@
1
+ import type {
2
+ AgentToolResult,
3
+ Theme,
4
+ ThemeColor,
5
+ ToolRenderResultOptions,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type {
8
+ Component,
9
+ DefaultTextStyle,
10
+ MarkdownTheme,
11
+ } from "@earendil-works/pi-tui";
12
+ import { Container, Markdown, visibleWidth } from "@earendil-works/pi-tui";
13
+ import { type PrefixSpec, Renderer } from "../../shared/Renderer";
14
+ import type { SubagentDetails, SubagentSnapshot } from "./subagent";
15
+
16
+ const DOT = "⬝";
17
+
18
+ type RenderContext = {
19
+ readonly lastComponent: Component | undefined;
20
+ readonly isPartial: boolean;
21
+ readonly isError: boolean;
22
+ };
23
+
24
+ type StatusFields = Pick<
25
+ SubagentSnapshot,
26
+ | "usage"
27
+ | "toolCalls"
28
+ | "activeToolNames"
29
+ | "lastToolName"
30
+ | "stopReason"
31
+ | "model"
32
+ | "contextWindow"
33
+ >;
34
+
35
+ type MarkdownBlockArgs = {
36
+ readonly text: string;
37
+ readonly theme: Theme;
38
+ readonly prefix: PrefixSpec;
39
+ readonly lineColor?: ThemeColor;
40
+ };
41
+
42
+ class MarkdownTitle implements Component {
43
+ private label = "";
44
+ private title = "";
45
+ private theme: Theme | undefined;
46
+ private context: RenderContext | undefined;
47
+ private labelColor: ThemeColor | undefined;
48
+
49
+ public set(args: {
50
+ readonly label: string;
51
+ readonly title: string;
52
+ readonly theme: Theme;
53
+ readonly context: RenderContext;
54
+ readonly labelColor?: ThemeColor;
55
+ }): void {
56
+ this.label = args.label;
57
+ this.title = args.title;
58
+ this.theme = args.theme;
59
+ this.context = args.context;
60
+ this.labelColor = args.labelColor;
61
+ }
62
+
63
+ public render(width: number): string[] {
64
+ const theme = this.theme;
65
+ const context = this.context;
66
+ if (!theme || !context) {
67
+ return [];
68
+ }
69
+
70
+ const markerColor = Renderer.markerColorFor(
71
+ Boolean(context.isPartial),
72
+ Boolean(context.isError)
73
+ );
74
+ const prefix =
75
+ theme.fg(markerColor, " ▪") +
76
+ " " +
77
+ theme.fg(this.labelColor ?? "toolTitle", theme.bold(this.label)) +
78
+ theme.fg("toolTitle", ": ");
79
+ const inner = Math.max(1, width - visibleWidth(prefix));
80
+ const titleLines = renderMarkdownLines({
81
+ text: this.title,
82
+ theme,
83
+ width: inner,
84
+ });
85
+ const lines = titleLines.length > 0 ? titleLines : [""];
86
+ const out = [padLine(prefix + (lines[0] ?? ""), width)];
87
+
88
+ for (const line of lines.slice(1)) {
89
+ out.push(
90
+ padLine(
91
+ theme.fg("toolOutput", Renderer.GAPPED_PREFIX.prefix) + line,
92
+ width
93
+ )
94
+ );
95
+ }
96
+
97
+ return out;
98
+ }
99
+
100
+ public invalidate(): void {}
101
+ }
102
+
103
+ export function formatCallTitle(prompt: string | undefined): string {
104
+ return (prompt ?? "...").split(/\r?\n/u)[0]?.trim() || "...";
105
+ }
106
+
107
+ export function formatTopLine(snapshot: StatusFields): string {
108
+ return [
109
+ formatCost(snapshot.usage.cost),
110
+ formatContext(snapshot),
111
+ snapshot.model ?? "unknown model",
112
+ formatActivity(snapshot),
113
+ ].join(` ${DOT} `);
114
+ }
115
+
116
+ export function renderCall(
117
+ args: { readonly prompt?: string } | undefined,
118
+ theme: Theme,
119
+ context: RenderContext
120
+ ): Component {
121
+ const component =
122
+ context.lastComponent instanceof MarkdownTitle
123
+ ? context.lastComponent
124
+ : new MarkdownTitle();
125
+ component.set({
126
+ label: "Subagent",
127
+ title: formatCallTitle(args?.prompt),
128
+ theme,
129
+ context,
130
+ labelColor: titleColorFor(context),
131
+ });
132
+ return component;
133
+ }
134
+
135
+ export function renderResult(
136
+ result: AgentToolResult<SubagentDetails>,
137
+ options: ToolRenderResultOptions,
138
+ theme: Theme,
139
+ context: RenderContext
140
+ ): Component {
141
+ const container =
142
+ context.lastComponent instanceof Container
143
+ ? context.lastComponent
144
+ : new Container();
145
+ container.clear();
146
+
147
+ const details = result.details;
148
+ const first = result.content?.[0];
149
+ const body = first && "text" in first ? (first.text ?? "") : "";
150
+ const topLine = details?.topLine;
151
+
152
+ if (topLine) {
153
+ container.addChild(
154
+ Renderer.makePrefixedBlock({
155
+ text: styleDottedLine({
156
+ text: topLine,
157
+ theme,
158
+ lineColor: options.isPartial ? "warning" : "accent",
159
+ }),
160
+ theme,
161
+ prefix: Renderer.GAPPED_PREFIX,
162
+ })
163
+ );
164
+ }
165
+
166
+ if (!options.isPartial && (!topLine || options.expanded) && body) {
167
+ container.addChild(
168
+ options.expanded
169
+ ? makePrefixedMarkdownBlock({
170
+ text: body,
171
+ theme,
172
+ prefix: Renderer.GAPPED_PREFIX,
173
+ lineColor: expandedResultColor(details, context.isError),
174
+ })
175
+ : Renderer.makePrefixedBlock({
176
+ text: body,
177
+ theme,
178
+ prefix: Renderer.GAPPED_PREFIX,
179
+ lineColor: resultColor(details, context.isError),
180
+ })
181
+ );
182
+ }
183
+
184
+ container.invalidate();
185
+ return container;
186
+ }
187
+
188
+ function makePrefixedMarkdownBlock(args: MarkdownBlockArgs): Component {
189
+ const markdown = new Markdown(
190
+ args.text,
191
+ 0,
192
+ 0,
193
+ makeMarkdownTheme(args.theme),
194
+ defaultStyle(args.theme, args.lineColor)
195
+ );
196
+
197
+ return {
198
+ render(width: number): string[] {
199
+ const inner = Math.max(1, width - args.prefix.width);
200
+ return markdown.render(inner).map((line) => {
201
+ return (
202
+ args.theme.fg("toolOutput", args.prefix.prefix) +
203
+ trimRenderedLine(line)
204
+ );
205
+ });
206
+ },
207
+ invalidate(): void {
208
+ markdown.invalidate();
209
+ },
210
+ };
211
+ }
212
+
213
+ function renderMarkdownLines(args: {
214
+ readonly text: string;
215
+ readonly theme: Theme;
216
+ readonly width: number;
217
+ readonly lineColor?: ThemeColor;
218
+ }): string[] {
219
+ const markdown = new Markdown(
220
+ args.text,
221
+ 0,
222
+ 0,
223
+ makeMarkdownTheme(args.theme),
224
+ defaultStyle(args.theme, args.lineColor)
225
+ );
226
+ return markdown.render(args.width).map(trimRenderedLine);
227
+ }
228
+
229
+ function defaultStyle(
230
+ theme: Theme,
231
+ lineColor: ThemeColor | undefined
232
+ ): DefaultTextStyle | undefined {
233
+ return lineColor
234
+ ? { color: (text: string) => theme.fg(lineColor, text) }
235
+ : undefined;
236
+ }
237
+
238
+ function makeMarkdownTheme(theme: Theme): MarkdownTheme {
239
+ return {
240
+ heading: (text: string) => theme.fg("mdHeading", text),
241
+ link: (text: string) => theme.fg("mdLink", text),
242
+ linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
243
+ code: (text: string) => theme.fg("mdCode", text),
244
+ codeBlock: (text: string) => theme.fg("mdCodeBlock", text),
245
+ codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text),
246
+ quote: (text: string) => theme.fg("mdQuote", text),
247
+ quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text),
248
+ hr: (text: string) => theme.fg("mdHr", text),
249
+ listBullet: (text: string) => theme.fg("mdListBullet", text),
250
+ bold: (text: string) => theme.bold(text),
251
+ italic: (text: string) => theme.italic(text),
252
+ underline: (text: string) => theme.underline(text),
253
+ strikethrough: (text: string) => theme.strikethrough(text),
254
+ };
255
+ }
256
+
257
+ function trimRenderedLine(line: string): string {
258
+ return line.trimEnd();
259
+ }
260
+
261
+ function padLine(line: string, width: number): string {
262
+ return line + " ".repeat(Math.max(0, width - visibleWidth(line)));
263
+ }
264
+
265
+ function styleDottedLine(args: {
266
+ readonly text: string;
267
+ readonly theme: Theme;
268
+ readonly lineColor: ThemeColor;
269
+ }): string {
270
+ return args.text
271
+ .split(DOT)
272
+ .map((part) => args.theme.fg(args.lineColor, part))
273
+ .join(args.theme.fg("muted", DOT));
274
+ }
275
+
276
+ function titleColorFor(context: RenderContext): ThemeColor {
277
+ if (context.isPartial) {
278
+ return "warning";
279
+ }
280
+ if (context.isError) {
281
+ return "error";
282
+ }
283
+ return "accent";
284
+ }
285
+
286
+ function resultColor(
287
+ details: SubagentDetails | undefined,
288
+ isError: boolean
289
+ ): "toolOutput" | "error" | "warning" {
290
+ if (details?.stopReason === "aborted") {
291
+ return "warning";
292
+ }
293
+ return isError ? "error" : "toolOutput";
294
+ }
295
+
296
+ function expandedResultColor(
297
+ details: SubagentDetails | undefined,
298
+ isError: boolean
299
+ ): "error" | "warning" | undefined {
300
+ if (details?.stopReason === "aborted") {
301
+ return "warning";
302
+ }
303
+ return isError ? "error" : undefined;
304
+ }
305
+
306
+ function formatActivity(snapshot: StatusFields): string {
307
+ const turns = `${snapshot.usage.turns} ${snapshot.usage.turns === 1 ? "turn" : "turns"}`;
308
+ if (snapshot.stopReason !== undefined) {
309
+ const toolCount = snapshot.toolCalls.length;
310
+ return toolCount > 0
311
+ ? `${turns} ${DOT} ${toolCount} ${toolCount === 1 ? "tool" : "tools"}`
312
+ : turns;
313
+ }
314
+
315
+ return `${turns} ${DOT} ${activeToolLabel(snapshot)}`;
316
+ }
317
+
318
+ function activeToolLabel(snapshot: StatusFields): string {
319
+ if (snapshot.activeToolNames.length === 1) {
320
+ return snapshot.activeToolNames[0]!;
321
+ }
322
+ if (snapshot.activeToolNames.length > 1) {
323
+ return `${snapshot.activeToolNames.length} tools`;
324
+ }
325
+ return snapshot.lastToolName ?? "thinking";
326
+ }
327
+
328
+ function formatContext(snapshot: StatusFields): string {
329
+ const window = snapshot.contextWindow;
330
+ if (!window || window <= 0) {
331
+ return "?/?";
332
+ }
333
+ const windowText = formatTokens(window);
334
+ const tokens = snapshot.usage.contextTokens;
335
+ if (tokens === undefined) {
336
+ return `?/${windowText}`;
337
+ }
338
+ return `${((tokens / window) * 100).toFixed(1)}%/${windowText}`;
339
+ }
340
+
341
+ function formatTokens(tokens: number): string {
342
+ if (tokens < 1000) {
343
+ return `${tokens}`;
344
+ }
345
+ if (tokens < 10_000) {
346
+ return `${(tokens / 1000).toFixed(1)}K`;
347
+ }
348
+ if (tokens < 1_000_000) {
349
+ return `${Math.round(tokens / 1000)}K`;
350
+ }
351
+ if (tokens < 10_000_000) {
352
+ return `${(tokens / 1_000_000).toFixed(1)}M`;
353
+ }
354
+ return `${Math.round(tokens / 1_000_000)}M`;
355
+ }
356
+
357
+ function formatCost(cost: number): string {
358
+ return `$${cost.toFixed(2)}`;
359
+ }
@@ -0,0 +1,9 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ export const subagentSchema = Type.Object({
4
+ prompt: Type.String({
5
+ minLength: 1,
6
+ }),
7
+ });
8
+
9
+ export type SubagentInput = Static<typeof subagentSchema>;
@@ -0,0 +1,315 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
4
+ import {
5
+ applyOutputCap,
6
+ childToolNames,
7
+ runSubagent,
8
+ SubagentEventCapture,
9
+ type SubagentSession,
10
+ } from "./subagent";
11
+
12
+ type UsageOverrides = Omit<Partial<Usage>, "cost"> & {
13
+ readonly cost?: Partial<Usage["cost"]>;
14
+ };
15
+
16
+ const usage = (overrides: UsageOverrides = {}): Usage => {
17
+ const { cost, ...rest } = overrides;
18
+ return {
19
+ input: 0,
20
+ output: 0,
21
+ cacheRead: 0,
22
+ cacheWrite: 0,
23
+ totalTokens: 0,
24
+ cost: {
25
+ input: 0,
26
+ output: 0,
27
+ cacheRead: 0,
28
+ cacheWrite: 0,
29
+ total: 0,
30
+ ...cost,
31
+ },
32
+ ...rest,
33
+ };
34
+ };
35
+
36
+ function assistant(
37
+ textParts: readonly string[],
38
+ overrides: Partial<AssistantMessage> = {}
39
+ ): AssistantMessage {
40
+ return {
41
+ role: "assistant",
42
+ content: textParts.map((text) => ({ type: "text", text })),
43
+ api: "anthropic-messages",
44
+ provider: "anthropic",
45
+ model: "claude-test",
46
+ usage: usage(),
47
+ stopReason: "stop",
48
+ timestamp: 1,
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ const ctx = { cwd: "/work" } as ExtensionContext;
54
+
55
+ class FakeSession implements SubagentSession {
56
+ public promptCalls = 0;
57
+ public abortCalls = 0;
58
+ public disposeCalls = 0;
59
+ private listener: ((event: never) => void) | undefined;
60
+
61
+ public constructor(
62
+ private readonly onPrompt: (
63
+ session: FakeSession,
64
+ prompt: string
65
+ ) => Promise<void>
66
+ ) {}
67
+
68
+ public subscribe(listener: (event: never) => void): () => void {
69
+ this.listener = listener;
70
+ return () => {
71
+ this.listener = undefined;
72
+ };
73
+ }
74
+
75
+ public emit(event: unknown): void {
76
+ this.listener?.(event as never);
77
+ }
78
+
79
+ public async prompt(prompt: string): Promise<void> {
80
+ this.promptCalls += 1;
81
+ await this.onPrompt(this, prompt);
82
+ }
83
+
84
+ public async abort(): Promise<void> {
85
+ this.abortCalls += 1;
86
+ }
87
+
88
+ public dispose(): void {
89
+ this.disposeCalls += 1;
90
+ }
91
+ }
92
+
93
+ describe("childToolNames", () => {
94
+ test("removes the subagent tool from a child's inherited allowlist", () => {
95
+ expect(childToolNames(["read", "subagent", "bash"])).toEqual([
96
+ "read",
97
+ "bash",
98
+ ]);
99
+ });
100
+ });
101
+
102
+ describe("SubagentEventCapture", () => {
103
+ test("concatenates multi-part text, resets for each assistant message, and records usage/tools", () => {
104
+ const updates: string[] = [];
105
+ const capture = new SubagentEventCapture((partial) => {
106
+ updates.push(
107
+ partial.content[0]?.type === "text" ? partial.content[0].text : ""
108
+ );
109
+ });
110
+
111
+ capture.handle({ type: "message_start", message: assistant([]) } as never);
112
+ capture.handle({
113
+ type: "message_end",
114
+ message: assistant(["first ", "turn"], {
115
+ usage: usage({ input: 10, output: 4, cost: { total: 0.01 } }),
116
+ }),
117
+ } as never);
118
+ capture.handle({
119
+ type: "tool_execution_end",
120
+ toolCallId: "1",
121
+ toolName: "read",
122
+ result: {},
123
+ isError: false,
124
+ } as never);
125
+ capture.handle({ type: "message_start", message: assistant([]) } as never);
126
+ capture.handle({
127
+ type: "message_end",
128
+ message: assistant(["final", " answer"], {
129
+ usage: usage({
130
+ input: 2,
131
+ output: 8,
132
+ cacheRead: 3,
133
+ cost: { total: 0.02 },
134
+ }),
135
+ }),
136
+ } as never);
137
+
138
+ const snapshot = capture.snapshot();
139
+ expect(snapshot.finalOutput).toBe("final answer");
140
+ expect(snapshot.usage).toEqual({
141
+ input: 12,
142
+ output: 12,
143
+ cacheRead: 3,
144
+ cacheWrite: 0,
145
+ cost: 0.03,
146
+ turns: 2,
147
+ contextTokens: undefined,
148
+ });
149
+ expect(snapshot.toolCalls).toEqual([{ name: "read", isError: false }]);
150
+ expect(snapshot.lastToolName).toBe("read");
151
+ expect(updates.at(-1)).toBe("$0.03 ⬝ ?/? ⬝ claude-test ⬝ 2 turns ⬝ 1 tool");
152
+ });
153
+
154
+ test("a later message with no text discards the prior message's text", () => {
155
+ const capture = new SubagentEventCapture();
156
+
157
+ capture.handle({ type: "message_start", message: assistant([]) } as never);
158
+ capture.handle({
159
+ type: "message_end",
160
+ message: assistant(["intro"], { stopReason: "toolUse" }),
161
+ } as never);
162
+ capture.handle({ type: "message_start", message: assistant([]) } as never);
163
+ capture.handle({
164
+ type: "message_end",
165
+ message: assistant([], { stopReason: "stop" }),
166
+ } as never);
167
+
168
+ expect(capture.snapshot().finalOutput).toBe("");
169
+ });
170
+
171
+ test("message_update content is materialized lazily on snapshot read", () => {
172
+ const capture = new SubagentEventCapture();
173
+
174
+ capture.handle({ type: "message_start", message: assistant([]) } as never);
175
+ capture.handle({
176
+ type: "message_update",
177
+ message: assistant(["partial"]),
178
+ } as never);
179
+
180
+ expect(capture.snapshot().finalOutput).toBe("partial");
181
+ });
182
+ });
183
+
184
+ describe("applyOutputCap", () => {
185
+ test("truncates on a UTF-8 boundary and reports omitted bytes", () => {
186
+ const capped = applyOutputCap("😀😀😀", 5);
187
+
188
+ expect(capped.text).toContain(
189
+ "😀\n[subagent: output truncated, 8 bytes omitted"
190
+ );
191
+ expect(capped.text).not.toContain("�");
192
+ expect(capped.truncated).toBe(true);
193
+ expect(capped.omittedBytes).toBe(8);
194
+ });
195
+ });
196
+
197
+ describe("runSubagent", () => {
198
+ test("returns bare text on normal completion", async () => {
199
+ const fake = new FakeSession(async (session) => {
200
+ session.emit({ type: "message_start", message: assistant([]) });
201
+ session.emit({
202
+ type: "message_end",
203
+ message: assistant(["hello"]),
204
+ });
205
+ });
206
+
207
+ const result = await runSubagent(
208
+ "say hi",
209
+ ctx,
210
+ undefined,
211
+ undefined,
212
+ async () => fake
213
+ );
214
+
215
+ expect(result.content).toEqual([{ type: "text", text: "hello" }]);
216
+ expect(result.details.fullOutput).toBe("hello");
217
+ expect(fake.promptCalls).toBe(1);
218
+ expect(fake.abortCalls).toBe(1);
219
+ expect(fake.disposeCalls).toBe(1);
220
+ });
221
+
222
+ test("returns a hint, not an error, for normal empty output", async () => {
223
+ const fake = new FakeSession(async (session) => {
224
+ session.emit({ type: "message_start", message: assistant([]) });
225
+ session.emit({ type: "message_end", message: assistant([]) });
226
+ });
227
+
228
+ const result = await runSubagent(
229
+ "empty",
230
+ ctx,
231
+ undefined,
232
+ undefined,
233
+ async () => fake
234
+ );
235
+
236
+ expect(
237
+ result.content[0]?.type === "text" ? result.content[0].text : ""
238
+ ).toBe("[subagent tool: completed with no text output.]");
239
+ });
240
+
241
+ test("throws on model error with partial output", async () => {
242
+ const fake = new FakeSession(async (session) => {
243
+ session.emit({ type: "message_start", message: assistant([]) });
244
+ session.emit({
245
+ type: "message_end",
246
+ message: assistant(["partial"], {
247
+ stopReason: "error",
248
+ errorMessage: "provider exploded",
249
+ }),
250
+ });
251
+ });
252
+
253
+ await expect(
254
+ runSubagent("fail", ctx, undefined, undefined, async () => fake)
255
+ ).rejects.toThrow(
256
+ "Subagent failed: error. Error: provider exploded.\nPartial output before failure:\npartial"
257
+ );
258
+ });
259
+
260
+ test("rejects pre-aborted signals before prompt", async () => {
261
+ const controller = new AbortController();
262
+ controller.abort();
263
+ const fake = new FakeSession(async () => {});
264
+
265
+ await expect(
266
+ runSubagent("abort", ctx, controller.signal, undefined, async () => fake)
267
+ ).rejects.toThrow("Subagent failed: subagent aborted before start");
268
+
269
+ expect(fake.promptCalls).toBe(0);
270
+ expect(fake.abortCalls).toBe(1);
271
+ expect(fake.disposeCalls).toBe(1);
272
+ });
273
+
274
+ test("mid-run abort aborts once, tears down, and rejects", async () => {
275
+ let finishPrompt: (() => void) | undefined;
276
+ const fake = new (class extends FakeSession {
277
+ public override async abort(): Promise<void> {
278
+ await super.abort();
279
+ finishPrompt?.();
280
+ }
281
+ })(
282
+ () =>
283
+ new Promise<void>((resolve) => {
284
+ finishPrompt = resolve;
285
+ })
286
+ );
287
+ const controller = new AbortController();
288
+ const promise = runSubagent(
289
+ "long",
290
+ ctx,
291
+ controller.signal,
292
+ undefined,
293
+ async () => fake
294
+ );
295
+
296
+ await Promise.resolve();
297
+ controller.abort();
298
+
299
+ await expect(promise).rejects.toThrow("Subagent failed: aborted");
300
+ expect(fake.promptCalls).toBe(1);
301
+ expect(fake.abortCalls).toBe(1);
302
+ expect(fake.disposeCalls).toBe(1);
303
+ });
304
+
305
+ test("nested subagent calls are rejected by the async-local recursion ban", async () => {
306
+ const outer = new FakeSession(async () => {
307
+ const inner = new FakeSession(async () => {});
308
+ await runSubagent("inner", ctx, undefined, undefined, async () => inner);
309
+ });
310
+
311
+ await expect(
312
+ runSubagent("outer", ctx, undefined, undefined, async () => outer)
313
+ ).rejects.toThrow("subagents cannot call subagent tool");
314
+ });
315
+ });