@gotgenes/pi-autoformat 0.1.0 → 4.0.3

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 (75) hide show
  1. package/.github/workflows/ci.yml +1 -3
  2. package/.github/workflows/release-please.yml +29 -0
  3. package/.markdownlint-cli2.yaml +14 -2
  4. package/.pi/extensions/pi-autoformat/config.json +3 -6
  5. package/.pi/prompts/README.md +59 -0
  6. package/.pi/prompts/plan-issue.md +64 -0
  7. package/.pi/prompts/retro.md +144 -0
  8. package/.pi/prompts/ship-issue.md +77 -0
  9. package/.pi/prompts/tdd-plan.md +67 -0
  10. package/.pi/skills/pi-extension-lifecycle/SKILL.md +256 -0
  11. package/.release-please-manifest.json +1 -1
  12. package/AGENTS.md +39 -0
  13. package/CHANGELOG.md +365 -0
  14. package/README.md +42 -109
  15. package/biome.json +1 -1
  16. package/docs/assets/logo.png +0 -0
  17. package/docs/assets/logo.svg +533 -0
  18. package/docs/configuration.md +358 -38
  19. package/docs/plans/0001-initial-implementation-plan.md +17 -9
  20. package/docs/plans/0002-richer-tui-formatter-summaries.md +220 -0
  21. package/docs/plans/0003-additional-pi-mutation-tools.md +273 -0
  22. package/docs/plans/0004-shell-driven-mutation-coverage.md +296 -0
  23. package/docs/plans/0010-acceptance-test-coverage.md +240 -0
  24. package/docs/plans/0012-remove-unused-formatter-extensions-field.md +152 -0
  25. package/docs/plans/0013-fallback-chain-step-type.md +280 -0
  26. package/docs/plans/0014-batch-by-default-formatter-dispatch.md +195 -0
  27. package/docs/plans/0015-builtin-treefmt-and-treefmt-nix-support.md +290 -0
  28. package/docs/plans/0016-detailed-formatter-output-on-failure.md +245 -0
  29. package/docs/plans/0022-pi-coding-agent-types.md +201 -0
  30. package/docs/plans/0027-format-before-agent-exit-follow-up-turn.md +355 -0
  31. package/docs/plans/0031-turn-end-flush-with-change-detection.md +365 -0
  32. package/docs/retro/0002-richer-tui-formatter-summaries.md +47 -0
  33. package/docs/retro/0013-fallback-chain-step-type.md +67 -0
  34. package/docs/retro/0015-builtin-treefmt-and-treefmt-nix-support.md +56 -0
  35. package/docs/retro/0016-detailed-formatter-output-on-failure.md +60 -0
  36. package/docs/retro/0022-pi-coding-agent-types.md +62 -0
  37. package/docs/testing.md +95 -0
  38. package/package.json +30 -11
  39. package/prek.toml +2 -2
  40. package/schemas/pi-autoformat.schema.json +145 -21
  41. package/src/builtin-formatters.ts +205 -0
  42. package/src/command-probe.ts +66 -0
  43. package/src/config-loader.ts +829 -90
  44. package/src/custom-mutation-tools.ts +125 -0
  45. package/src/extension.ts +469 -82
  46. package/src/format-scope.ts +118 -0
  47. package/src/formatter-config.ts +73 -36
  48. package/src/formatter-executor.ts +230 -34
  49. package/src/formatter-output-report.ts +149 -0
  50. package/src/formatter-registry.ts +139 -30
  51. package/src/index.ts +26 -5
  52. package/src/prompt-autoformatter.ts +148 -23
  53. package/src/shell-mutation-detector.ts +572 -0
  54. package/src/touched-files-queue.ts +72 -11
  55. package/test/acceptance-event-bus.test.ts +138 -0
  56. package/test/acceptance.test.ts +69 -0
  57. package/test/builtin-formatters.test.ts +382 -0
  58. package/test/command-probe.test.ts +79 -0
  59. package/test/config-loader.test.ts +640 -21
  60. package/test/custom-mutation-tools.test.ts +190 -0
  61. package/test/extension.test.ts +1535 -158
  62. package/test/fallback-acceptance.test.ts +98 -0
  63. package/test/fixtures/event-bus-emitter.ts +26 -0
  64. package/test/fixtures/formatter-recorder.mjs +25 -0
  65. package/test/format-scope.test.ts +139 -0
  66. package/test/formatter-config.test.ts +56 -5
  67. package/test/formatter-executor.test.ts +555 -35
  68. package/test/formatter-output-report.test.ts +178 -0
  69. package/test/formatter-registry.test.ts +330 -37
  70. package/test/helpers/rpc.ts +146 -0
  71. package/test/prompt-autoformatter.test.ts +315 -22
  72. package/test/schema.test.ts +149 -0
  73. package/test/shell-mutation-detector.test.ts +221 -0
  74. package/test/touched-files-queue.test.ts +40 -1
  75. package/test/types/theme-stub.test-d.ts +42 -0
@@ -1,33 +1,134 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ Theme,
5
+ } from "@mariozechner/pi-coding-agent";
1
6
  import { describe, expect, it, vi } from "vitest";
2
7
 
3
8
  import type { LoadConfigResult } from "../src/config-loader.js";
4
- import { createAutoformatExtension } from "../src/extension.js";
9
+ import {
10
+ buildSteeringMessageContent,
11
+ createAutoformatExtension,
12
+ createDefaultAutoformatter,
13
+ } from "../src/extension.js";
5
14
  import { createFormatterConfig } from "../src/formatter-config.js";
6
15
  import type { PromptAutoformatterResult } from "../src/prompt-autoformatter.js";
7
16
 
8
- type Handler = (event: unknown, ctx: TestContext) => void | Promise<void>;
17
+ type Handler = (event: never, ctx: TestContext) => void | Promise<void>;
9
18
 
10
19
  type EventName =
11
20
  | "session_start"
21
+ | "tool_call"
12
22
  | "tool_result"
23
+ | "turn_end"
13
24
  | "agent_end"
14
25
  | "session_shutdown";
15
26
 
27
+ /**
28
+ * Class-based stub that mirrors Pi's real `Theme.fg` `this`-binding
29
+ * requirement. Plain object literals like `{ fg: (n, t) => t }` would
30
+ * not surface the regression fixed in 6a6ec16, so every test ctx must
31
+ * use this stub (or a real Theme instance).
32
+ */
33
+ class StubTheme {
34
+ private readonly fgColors = new Map<string, string>([
35
+ ["success", ""],
36
+ ["warning", ""],
37
+ ["error", ""],
38
+ ["dim", ""],
39
+ ["accent", ""],
40
+ ]);
41
+
42
+ fg(color: string, text: string): string {
43
+ if (!this.fgColors.has(color)) {
44
+ throw new Error(`Unknown theme color: ${color}`);
45
+ }
46
+ return text;
47
+ }
48
+ }
49
+
50
+ /** Build a `Theme`-shaped value backed by `StubTheme`. */
51
+ function makeStubTheme(): Theme {
52
+ return new StubTheme() as unknown as Theme;
53
+ }
54
+
55
+ /**
56
+ * Narrowed `ExtensionContext` view used by the autoformat extension. The
57
+ * real `ExtensionContext` requires sessionManager/modelRegistry/etc. that
58
+ * the autoformatter never touches, so tests fabricate only the surface
59
+ * exercised here. `ui.theme` is anchored to the real `Theme` type so
60
+ * plain-arrow stubs are rejected at compile time.
61
+ */
16
62
  type TestContext = {
17
63
  cwd: string;
18
64
  hasUI: boolean;
19
65
  ui: {
20
66
  notify(message: string, type?: "info" | "warning" | "error"): void;
67
+ setStatus?: (key: string, text: string | undefined) => void;
68
+ theme?: Theme;
21
69
  };
22
70
  };
23
71
 
24
72
  class TestPi {
25
73
  private readonly handlers = new Map<EventName, Handler[]>();
74
+ private readonly busHandlers = new Map<
75
+ string,
76
+ Array<(data: unknown) => void>
77
+ >();
26
78
 
27
- on(eventName: EventName, handler: Handler): void {
79
+ // Cast through unknown: TestPi only models the events we exercise, not
80
+ // ExtensionAPI's full overload set. The boundary cast lives here once
81
+ // and the autoformat-side typing remains anchored to ExtensionAPI.
82
+ readonly on = ((eventName: EventName, handler: Handler): void => {
28
83
  const existing = this.handlers.get(eventName) ?? [];
29
84
  existing.push(handler);
30
85
  this.handlers.set(eventName, existing);
86
+ }) as unknown as ExtensionAPI["on"];
87
+
88
+ readonly events: ExtensionAPI["events"] = {
89
+ emit: (_channel: string, _data: unknown): void => {
90
+ // Tests use `emitBus` for clarity; the EventBus.emit is a no-op in
91
+ // tests because no real bus producers run.
92
+ },
93
+ on: (channel, handler) => {
94
+ const existing = this.busHandlers.get(channel) ?? [];
95
+ existing.push(handler);
96
+ this.busHandlers.set(channel, existing);
97
+ return () => {
98
+ const current = this.busHandlers.get(channel) ?? [];
99
+ this.busHandlers.set(
100
+ channel,
101
+ current.filter((h) => h !== handler),
102
+ );
103
+ };
104
+ },
105
+ };
106
+
107
+ readonly sentMessages: Array<{
108
+ message: Record<string, unknown>;
109
+ options?: Record<string, unknown>;
110
+ }> = [];
111
+
112
+ readonly sendMessage = ((message: unknown, options?: unknown): void => {
113
+ this.sentMessages.push({
114
+ message: message as Record<string, unknown>,
115
+ options: options as Record<string, unknown> | undefined,
116
+ });
117
+ }) as unknown as ExtensionAPI["sendMessage"];
118
+
119
+ /** Cast helper: TestPi satisfies the slice of ExtensionAPI under test. */
120
+ asExtensionAPI(): ExtensionAPI {
121
+ return this as unknown as ExtensionAPI;
122
+ }
123
+
124
+ emitBus(channel: string, data: unknown): void {
125
+ for (const handler of this.busHandlers.get(channel) ?? []) {
126
+ handler(data);
127
+ }
128
+ }
129
+
130
+ busHandlerCount(channel: string): number {
131
+ return (this.busHandlers.get(channel) ?? []).length;
31
132
  }
32
133
 
33
134
  async emit(
@@ -36,16 +137,14 @@ class TestPi {
36
137
  ctx: TestContext,
37
138
  ): Promise<void> {
38
139
  for (const handler of this.handlers.get(eventName) ?? []) {
39
- await handler(event, ctx);
140
+ await handler(event as never, ctx as unknown as ExtensionContext);
40
141
  }
41
142
  }
42
143
  }
43
144
 
44
- function createLoadResult(
45
- formatMode: "tool" | "prompt" | "session",
46
- ): LoadConfigResult {
145
+ function createLoadResult(): LoadConfigResult {
47
146
  return {
48
- config: createFormatterConfig({ formatMode }),
147
+ config: createFormatterConfig(),
49
148
  globalConfigPath: "/global/config.json",
50
149
  projectConfigPath: "/project/config.json",
51
150
  issues: [],
@@ -58,6 +157,8 @@ function createContext(overrides?: Partial<TestContext>): TestContext {
58
157
  hasUI: true,
59
158
  ui: {
60
159
  notify: vi.fn(),
160
+ setStatus: vi.fn(),
161
+ theme: makeStubTheme(),
61
162
  },
62
163
  ...overrides,
63
164
  };
@@ -65,48 +166,184 @@ function createContext(overrides?: Partial<TestContext>): TestContext {
65
166
 
66
167
  function createFlushResult(): PromptAutoformatterResult {
67
168
  return {
68
- files: [
169
+ groups: [
69
170
  {
70
- path: "/repo/src/example.ts",
71
- runs: [],
171
+ chain: ["prettier"],
172
+ files: ["/repo/src/example.ts"],
173
+ runs: [
174
+ {
175
+ formatterName: "prettier",
176
+ command: ["prettier", "--write", "/repo/src/example.ts"],
177
+ files: ["/repo/src/example.ts"],
178
+ success: true,
179
+ exitCode: 0,
180
+ },
181
+ ],
182
+ changedFiles: [],
72
183
  },
73
184
  ],
74
185
  };
75
186
  }
76
187
 
77
188
  describe("createAutoformatExtension", () => {
78
- it("reports interactive success summaries with touched file paths", async () => {
189
+ it("preserves the theme `this` binding when coloring the status line", async () => {
190
+ // Regression: Pi's real Theme.fg is an instance method that reads
191
+ // `this.fgColors`. If our extension destructures the method off the
192
+ // theme object and calls it standalone, `this` is undefined and the
193
+ // call throws "Cannot read properties of undefined (reading
194
+ // 'fgColors')". The module-level `StubTheme` reproduces that shape.
195
+ const pi = new TestPi();
196
+ const notify = vi.fn();
197
+ const setStatus = vi.fn();
198
+ const ctx = createContext({
199
+ ui: { notify, setStatus, theme: makeStubTheme() },
200
+ });
201
+
202
+ createAutoformatExtension(pi.asExtensionAPI(), {
203
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
204
+ createAutoformatter: vi.fn().mockReturnValue({
205
+ recordToolResult: vi.fn(),
206
+ flushPrompt: vi.fn().mockResolvedValue(createFlushResult()),
207
+ }),
208
+ });
209
+
210
+ await pi.emit("session_start", {}, ctx);
211
+ setStatus.mockClear();
212
+ await pi.emit("agent_end", {}, ctx);
213
+
214
+ // The flush must succeed cleanly: no "Unexpected runtime error" warning,
215
+ // and a normal success status was written.
216
+ const warningCalls = notify.mock.calls.filter((c) => c[1] === "warning");
217
+ expect(warningCalls).toEqual([]);
218
+ expect(setStatus).toHaveBeenCalledWith(
219
+ "autoformat",
220
+ expect.stringContaining("autoformat:"),
221
+ );
222
+ });
223
+
224
+ it("clears the autoformat status on an empty flush in the TUI", async () => {
225
+ const pi = new TestPi();
226
+ const notify = vi.fn();
227
+ const setStatus = vi.fn();
228
+ const ctx = createContext({
229
+ ui: {
230
+ notify,
231
+ setStatus,
232
+ theme: makeStubTheme(),
233
+ },
234
+ });
235
+
236
+ createAutoformatExtension(pi.asExtensionAPI(), {
237
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
238
+ createAutoformatter: vi.fn().mockReturnValue({
239
+ recordToolResult: vi.fn(),
240
+ flushPrompt: vi.fn().mockResolvedValue({ groups: [] }),
241
+ }),
242
+ });
243
+
244
+ await pi.emit("session_start", {}, ctx);
245
+ setStatus.mockClear();
246
+ await pi.emit("agent_end", {}, ctx);
247
+
248
+ expect(setStatus).toHaveBeenCalledWith("autoformat", undefined);
249
+ expect(notify).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("reports interactive success summaries via the footer status", async () => {
253
+ const pi = new TestPi();
254
+ const notify = vi.fn();
255
+ const setStatus = vi.fn();
256
+ const ctx = createContext({
257
+ ui: {
258
+ notify,
259
+ setStatus,
260
+ theme: makeStubTheme(),
261
+ },
262
+ });
263
+
264
+ createAutoformatExtension(pi.asExtensionAPI(), {
265
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
266
+ createAutoformatter: vi.fn().mockReturnValue({
267
+ recordToolResult: vi.fn(),
268
+ flushPrompt: vi.fn().mockResolvedValue({
269
+ groups: [
270
+ {
271
+ chain: ["prettier"],
272
+ files: ["/repo/src/example.ts", "/repo/README.md"],
273
+ runs: [
274
+ {
275
+ formatterName: "prettier",
276
+ command: [],
277
+ files: ["/repo/src/example.ts", "/repo/README.md"],
278
+ success: true,
279
+ exitCode: 0,
280
+ },
281
+ ],
282
+ },
283
+ ],
284
+ }),
285
+ }),
286
+ });
287
+
288
+ await pi.emit("session_start", {}, ctx);
289
+ setStatus.mockClear();
290
+ await pi.emit("agent_end", {}, ctx);
291
+
292
+ expect(setStatus).toHaveBeenCalledTimes(1);
293
+ const [statusKey, statusText] = setStatus.mock.calls[0];
294
+ expect(statusKey).toBe("autoformat");
295
+ expect(statusText).toContain("autoformat:");
296
+ expect(statusText).toContain("2 files");
297
+ expect(statusText).toContain("prettier");
298
+ expect(notify).not.toHaveBeenCalled();
299
+ });
300
+
301
+ it("counts files across multiple groups in the success summary", async () => {
79
302
  const pi = new TestPi();
80
303
  const notify = vi.fn();
304
+ const setStatus = vi.fn();
81
305
  const ctx = createContext({
82
306
  ui: {
83
307
  notify,
308
+ setStatus,
309
+ theme: makeStubTheme(),
84
310
  },
85
311
  });
86
312
 
87
- createAutoformatExtension(pi, {
88
- loadConfig: vi.fn().mockReturnValue(createLoadResult("prompt")),
313
+ createAutoformatExtension(pi.asExtensionAPI(), {
314
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
89
315
  createAutoformatter: vi.fn().mockReturnValue({
90
316
  recordToolResult: vi.fn(),
91
317
  flushPrompt: vi.fn().mockResolvedValue({
92
- files: [
318
+ groups: [
93
319
  {
94
- path: "/repo/src/example.ts",
320
+ chain: ["prettier"],
321
+ files: ["/repo/a.ts", "/repo/b.ts"],
95
322
  runs: [
96
323
  {
97
324
  formatterName: "prettier",
98
325
  command: [],
326
+ files: ["/repo/a.ts", "/repo/b.ts"],
99
327
  success: true,
100
328
  exitCode: 0,
101
329
  },
102
330
  ],
103
331
  },
104
332
  {
105
- path: "/repo/README.md",
333
+ chain: ["prettier", "markdownlint"],
334
+ files: ["/repo/c.md"],
106
335
  runs: [
107
336
  {
108
337
  formatterName: "prettier",
109
338
  command: [],
339
+ files: ["/repo/c.md"],
340
+ success: true,
341
+ exitCode: 0,
342
+ },
343
+ {
344
+ formatterName: "markdownlint",
345
+ command: [],
346
+ files: ["/repo/c.md"],
110
347
  success: true,
111
348
  exitCode: 0,
112
349
  },
@@ -118,67 +355,107 @@ describe("createAutoformatExtension", () => {
118
355
  });
119
356
 
120
357
  await pi.emit("session_start", {}, ctx);
358
+ setStatus.mockClear();
121
359
  await pi.emit("agent_end", {}, ctx);
122
360
 
123
- expect(notify).toHaveBeenCalledWith(
124
- "Autoformatted 2 files: /repo/src/example.ts, /repo/README.md",
125
- "info",
126
- );
361
+ const [, statusText] = setStatus.mock.calls[0];
362
+ expect(statusText).toContain("3 files");
363
+ expect(statusText).toContain("prettier");
364
+ expect(statusText).toContain("markdownlint");
365
+ expect(notify).not.toHaveBeenCalled();
127
366
  });
128
367
 
129
- it("hides interactive success summaries when configured", async () => {
368
+ it("reports per-batch failure lines listing each batch's files", async () => {
130
369
  const pi = new TestPi();
131
370
  const notify = vi.fn();
371
+ const setStatus = vi.fn();
372
+ const fg = vi.fn((_name: string, text: string) => text);
373
+ class SpyTheme {
374
+ fg = fg;
375
+ }
132
376
  const ctx = createContext({
133
377
  ui: {
134
378
  notify,
379
+ setStatus,
380
+ theme: new SpyTheme() as unknown as Theme,
135
381
  },
136
382
  });
137
383
 
138
- createAutoformatExtension(pi, {
139
- loadConfig: vi.fn().mockReturnValue({
140
- ...createLoadResult("prompt"),
141
- config: createFormatterConfig({
142
- formatMode: "prompt",
143
- hideSummariesInTui: true,
144
- }),
145
- }),
384
+ createAutoformatExtension(pi.asExtensionAPI(), {
385
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
146
386
  createAutoformatter: vi.fn().mockReturnValue({
147
387
  recordToolResult: vi.fn(),
148
- flushPrompt: vi.fn().mockResolvedValue(createFlushResult()),
388
+ flushPrompt: vi.fn().mockResolvedValue({
389
+ groups: [
390
+ {
391
+ chain: ["prettier"],
392
+ files: ["/repo/a.ts", "/repo/b.ts"],
393
+ runs: [
394
+ {
395
+ formatterName: "prettier",
396
+ command: [],
397
+ files: ["/repo/a.ts", "/repo/b.ts"],
398
+ success: false,
399
+ exitCode: 2,
400
+ },
401
+ ],
402
+ },
403
+ ],
404
+ }),
149
405
  }),
150
406
  });
151
407
 
152
408
  await pi.emit("session_start", {}, ctx);
153
409
  await pi.emit("agent_end", {}, ctx);
154
410
 
155
- expect(notify).not.toHaveBeenCalled();
411
+ expect(notify).toHaveBeenCalledWith(
412
+ "Formatter failures in 1 batch:\nprettier (exit 2): /repo/a.ts, /repo/b.ts",
413
+ "warning",
414
+ );
415
+ const failureStatusCalls = setStatus.mock.calls.filter(
416
+ (c) => c[1] !== undefined,
417
+ );
418
+ expect(failureStatusCalls).toHaveLength(1);
419
+ const [statusKey, statusText] = failureStatusCalls[0];
420
+ expect(statusKey).toBe("autoformat");
421
+ expect(statusText).toContain("1 batch failed");
422
+ expect(statusText).toContain("prettier");
423
+ expect(fg).toHaveBeenCalledWith("error", expect.any(String));
156
424
  });
157
425
 
158
- it("reports non-interactive formatter failures via console warnings", async () => {
426
+ it("shows mixed-result failures with surviving success batches in the status", async () => {
159
427
  const pi = new TestPi();
160
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
161
- const log = vi.spyOn(console, "log").mockImplementation(() => {});
162
- const ctx = createContext({ hasUI: false });
428
+ const notify = vi.fn();
429
+ const setStatus = vi.fn();
430
+ const ctx = createContext({
431
+ ui: {
432
+ notify,
433
+ setStatus,
434
+ theme: makeStubTheme(),
435
+ },
436
+ });
163
437
 
164
- createAutoformatExtension(pi, {
165
- loadConfig: vi.fn().mockReturnValue(createLoadResult("prompt")),
438
+ createAutoformatExtension(pi.asExtensionAPI(), {
439
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
166
440
  createAutoformatter: vi.fn().mockReturnValue({
167
441
  recordToolResult: vi.fn(),
168
442
  flushPrompt: vi.fn().mockResolvedValue({
169
- files: [
443
+ groups: [
170
444
  {
171
- path: "/repo/README.md",
445
+ chain: ["prettier", "markdownlint"],
446
+ files: ["/repo/x.md"],
172
447
  runs: [
173
448
  {
174
449
  formatterName: "prettier",
175
- command: ["prettier", "--write", "/repo/README.md"],
176
- success: false,
177
- exitCode: 2,
450
+ command: [],
451
+ files: ["/repo/x.md"],
452
+ success: true,
453
+ exitCode: 0,
178
454
  },
179
455
  {
180
- formatterName: "markdownlint-cli2",
181
- command: ["markdownlint-cli2", "--fix", "/repo/README.md"],
456
+ formatterName: "markdownlint",
457
+ command: [],
458
+ files: ["/repo/x.md"],
182
459
  success: false,
183
460
  exitCode: 1,
184
461
  },
@@ -190,175 +467,1275 @@ describe("createAutoformatExtension", () => {
190
467
  });
191
468
 
192
469
  await pi.emit("session_start", {}, ctx);
470
+ setStatus.mockClear();
193
471
  await pi.emit("agent_end", {}, ctx);
194
472
 
195
- expect(warn).toHaveBeenCalledWith(
196
- "[pi-autoformat] Formatter failures in 1 file (2 failed runs):\n/repo/README.md: prettier (exit 2), markdownlint-cli2 (exit 1)",
473
+ expect(notify).toHaveBeenCalledWith(
474
+ expect.stringContaining("markdownlint (exit 1): /repo/x.md"),
475
+ "warning",
197
476
  );
198
- expect(log).not.toHaveBeenCalled();
199
-
200
- warn.mockRestore();
201
- log.mockRestore();
477
+ const failureStatus = setStatus.mock.calls.find((c) => c[1] !== undefined);
478
+ expect(failureStatus).toBeDefined();
479
+ const text = failureStatus?.[1] as string;
480
+ expect(text).toContain("1 batch failed");
481
+ expect(text).toContain("1 ok");
202
482
  });
203
483
 
204
- it("reports non-interactive config issues via console warnings", async () => {
484
+ it("renders fallback context in success summaries when present", async () => {
205
485
  const pi = new TestPi();
206
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
207
- const ctx = createContext({ hasUI: false });
486
+ const notify = vi.fn();
487
+ const setStatus = vi.fn();
488
+ const ctx = createContext({
489
+ ui: {
490
+ notify,
491
+ setStatus,
492
+ theme: makeStubTheme(),
493
+ },
494
+ });
208
495
 
209
- createAutoformatExtension(pi, {
210
- loadConfig: vi.fn().mockReturnValue({
211
- ...createLoadResult("prompt"),
212
- issues: [
213
- {
214
- path: "formatMode",
215
- message: "Expected a valid mode.",
216
- sourcePath: "/repo/.pi/extensions/pi-autoformat/config.json",
217
- },
218
- ],
219
- }),
496
+ createAutoformatExtension(pi.asExtensionAPI(), {
497
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
220
498
  createAutoformatter: vi.fn().mockReturnValue({
221
499
  recordToolResult: vi.fn(),
222
- flushPrompt: vi.fn().mockResolvedValue({ files: [] }),
500
+ flushPrompt: vi.fn().mockResolvedValue({
501
+ groups: [
502
+ {
503
+ chain: [{ fallback: ["biome", "prettier"] }],
504
+ files: ["/repo/a.ts"],
505
+ runs: [
506
+ {
507
+ formatterName: "prettier",
508
+ command: ["prettier", "--write", "/repo/a.ts"],
509
+ files: ["/repo/a.ts"],
510
+ success: true,
511
+ exitCode: 0,
512
+ fallbackContext: { skipped: ["biome"] },
513
+ },
514
+ ],
515
+ },
516
+ ],
517
+ }),
223
518
  }),
224
519
  });
225
520
 
226
521
  await pi.emit("session_start", {}, ctx);
522
+ setStatus.mockClear();
523
+ await pi.emit("agent_end", {}, ctx);
227
524
 
228
- expect(warn).toHaveBeenCalledWith(
229
- "[pi-autoformat] Configuration issues detected:\n/repo/.pi/extensions/pi-autoformat/config.json formatMode: Expected a valid mode.",
230
- );
231
-
232
- warn.mockRestore();
525
+ const statusTexts = setStatus.mock.calls.map((c) => c[1] as string);
526
+ expect(
527
+ statusTexts.some((m) =>
528
+ /prettier \(fallback after biome unavailable\)/.test(m),
529
+ ),
530
+ ).toBe(true);
233
531
  });
234
532
 
235
- it("records successful tool results and flushes at prompt end in prompt mode", async () => {
533
+ it("renders fallback context in failure summaries when present", async () => {
236
534
  const pi = new TestPi();
237
- const ctx = createContext();
238
- const autoformatter = {
239
- recordToolResult: vi.fn(),
240
- flushPrompt: vi.fn().mockResolvedValue(createFlushResult()),
241
- };
242
- const reportFlushResult = vi.fn();
535
+ const notify = vi.fn();
536
+ const ctx = createContext({ ui: { notify } });
243
537
 
244
- createAutoformatExtension(pi, {
245
- loadConfig: vi.fn().mockReturnValue(createLoadResult("prompt")),
246
- createAutoformatter: vi.fn().mockReturnValue(autoformatter),
247
- reportFlushResult,
538
+ createAutoformatExtension(pi.asExtensionAPI(), {
539
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
540
+ createAutoformatter: vi.fn().mockReturnValue({
541
+ recordToolResult: vi.fn(),
542
+ flushPrompt: vi.fn().mockResolvedValue({
543
+ groups: [
544
+ {
545
+ chain: [{ fallback: ["biome", "prettier"] }],
546
+ files: ["/repo/a.ts"],
547
+ runs: [
548
+ {
549
+ formatterName: "prettier",
550
+ command: ["prettier", "--write", "/repo/a.ts"],
551
+ files: ["/repo/a.ts"],
552
+ success: false,
553
+ exitCode: 2,
554
+ fallbackContext: { skipped: ["biome"] },
555
+ },
556
+ ],
557
+ },
558
+ ],
559
+ }),
560
+ }),
248
561
  });
249
562
 
250
563
  await pi.emit("session_start", {}, ctx);
251
- await pi.emit(
252
- "tool_result",
253
- {
254
- toolName: "write",
255
- input: { path: "src/example.ts", content: "export {};" },
256
- isError: false,
257
- },
258
- ctx,
259
- );
260
564
  await pi.emit("agent_end", {}, ctx);
261
565
 
262
- expect(autoformatter.recordToolResult).toHaveBeenCalledWith("write", {
263
- path: "src/example.ts",
264
- content: "export {};",
265
- });
266
- expect(autoformatter.flushPrompt).toHaveBeenCalledTimes(1);
267
- expect(reportFlushResult).toHaveBeenCalledTimes(1);
566
+ expect(notify).toHaveBeenCalledWith(
567
+ "Formatter failures in 1 batch:\nprettier (fallback after biome unavailable) (exit 2): /repo/a.ts",
568
+ "warning",
569
+ );
268
570
  });
269
571
 
270
- it("flushes immediately in tool mode", async () => {
271
- const pi = new TestPi();
272
- const ctx = createContext();
273
- const autoformatter = {
274
- recordToolResult: vi.fn(),
275
- flushPrompt: vi.fn().mockResolvedValue({ files: [] }),
276
- };
572
+ describe("formatterOutput surfacing", () => {
573
+ function makeFailedFlushResult(stdout: string, stderr: string) {
574
+ return {
575
+ groups: [
576
+ {
577
+ chain: ["prettier"],
578
+ files: ["/repo/a.ts"],
579
+ runs: [
580
+ {
581
+ formatterName: "prettier",
582
+ command: ["prettier", "--write", "/repo/a.ts"],
583
+ files: ["/repo/a.ts"],
584
+ success: false,
585
+ exitCode: 2,
586
+ stdout,
587
+ stderr,
588
+ },
589
+ ],
590
+ },
591
+ ],
592
+ };
593
+ }
277
594
 
278
- createAutoformatExtension(pi, {
279
- loadConfig: vi.fn().mockReturnValue(createLoadResult("tool")),
280
- createAutoformatter: vi.fn().mockReturnValue(autoformatter),
281
- reportFlushResult: vi.fn(),
595
+ it("omits stdout/stderr by default (onFailure: none)", async () => {
596
+ const pi = new TestPi();
597
+ const notify = vi.fn();
598
+ const ctx = createContext({ ui: { notify } });
599
+
600
+ createAutoformatExtension(pi.asExtensionAPI(), {
601
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
602
+ createAutoformatter: vi.fn().mockReturnValue({
603
+ recordToolResult: vi.fn(),
604
+ flushPrompt: vi
605
+ .fn()
606
+ .mockResolvedValue(makeFailedFlushResult("out", "err")),
607
+ }),
608
+ });
609
+
610
+ await pi.emit("session_start", {}, ctx);
611
+ await pi.emit("agent_end", {}, ctx);
612
+
613
+ expect(notify).toHaveBeenCalledWith(
614
+ "Formatter failures in 1 batch:\nprettier (exit 2): /repo/a.ts",
615
+ "warning",
616
+ );
282
617
  });
283
618
 
284
- await pi.emit(
285
- "tool_result",
286
- {
287
- toolName: "edit",
288
- input: { path: "src/example.ts", edits: [] },
289
- isError: false,
290
- },
291
- ctx,
292
- );
619
+ it("appends only stderr under onFailure: stderr", async () => {
620
+ const pi = new TestPi();
621
+ const notify = vi.fn();
622
+ const ctx = createContext({ ui: { notify } });
293
623
 
294
- expect(autoformatter.recordToolResult).toHaveBeenCalledTimes(1);
295
- expect(autoformatter.flushPrompt).toHaveBeenCalledTimes(1);
296
- });
624
+ createAutoformatExtension(pi.asExtensionAPI(), {
625
+ loadConfig: vi.fn().mockReturnValue({
626
+ ...createLoadResult(),
627
+ config: createFormatterConfig({
628
+ formatterOutput: { onFailure: "stderr" },
629
+ }),
630
+ }),
631
+ createAutoformatter: vi.fn().mockReturnValue({
632
+ recordToolResult: vi.fn(),
633
+ flushPrompt: vi
634
+ .fn()
635
+ .mockResolvedValue(
636
+ makeFailedFlushResult("chatty stdout", "boom!\nat foo"),
637
+ ),
638
+ }),
639
+ });
297
640
 
298
- it("flushes on session shutdown in session mode and ignores failed tool results", async () => {
299
- const pi = new TestPi();
300
- const ctx = createContext();
301
- const autoformatter = {
302
- recordToolResult: vi.fn(),
303
- flushPrompt: vi.fn().mockResolvedValue({ files: [] }),
304
- };
641
+ await pi.emit("session_start", {}, ctx);
642
+ await pi.emit("agent_end", {}, ctx);
305
643
 
306
- createAutoformatExtension(pi, {
307
- loadConfig: vi.fn().mockReturnValue(createLoadResult("session")),
308
- createAutoformatter: vi.fn().mockReturnValue(autoformatter),
309
- reportFlushResult: vi.fn(),
644
+ const [message] = notify.mock.calls[0];
645
+ expect(message).toContain("prettier (exit 2): /repo/a.ts");
646
+ expect(message).toContain(" stderr:");
647
+ expect(message).toContain(" boom!");
648
+ expect(message).toContain(" at foo");
649
+ expect(message).not.toContain(" stdout:");
650
+ expect(message).not.toContain("chatty stdout");
310
651
  });
311
652
 
312
- await pi.emit("session_start", {}, ctx);
313
- await pi.emit(
653
+ it("appends stdout above stderr under onFailure: both", async () => {
654
+ const pi = new TestPi();
655
+ const notify = vi.fn();
656
+ const ctx = createContext({ ui: { notify } });
657
+
658
+ createAutoformatExtension(pi.asExtensionAPI(), {
659
+ loadConfig: vi.fn().mockReturnValue({
660
+ ...createLoadResult(),
661
+ config: createFormatterConfig({
662
+ formatterOutput: { onFailure: "both" },
663
+ }),
664
+ }),
665
+ createAutoformatter: vi.fn().mockReturnValue({
666
+ recordToolResult: vi.fn(),
667
+ flushPrompt: vi
668
+ .fn()
669
+ .mockResolvedValue(makeFailedFlushResult("out line", "err line")),
670
+ }),
671
+ });
672
+
673
+ await pi.emit("session_start", {}, ctx);
674
+ await pi.emit("agent_end", {}, ctx);
675
+
676
+ const [message] = notify.mock.calls[0];
677
+ const stdoutIdx = message.indexOf(" stdout:");
678
+ const stderrIdx = message.indexOf(" stderr:");
679
+ expect(stdoutIdx).toBeGreaterThanOrEqual(0);
680
+ expect(stderrIdx).toBeGreaterThan(stdoutIdx);
681
+ expect(message).toContain(" out line");
682
+ expect(message).toContain(" err line");
683
+ });
684
+
685
+ it("omits an empty stdout block under onFailure: both", async () => {
686
+ const pi = new TestPi();
687
+ const notify = vi.fn();
688
+ const ctx = createContext({ ui: { notify } });
689
+
690
+ createAutoformatExtension(pi.asExtensionAPI(), {
691
+ loadConfig: vi.fn().mockReturnValue({
692
+ ...createLoadResult(),
693
+ config: createFormatterConfig({
694
+ formatterOutput: { onFailure: "both" },
695
+ }),
696
+ }),
697
+ createAutoformatter: vi.fn().mockReturnValue({
698
+ recordToolResult: vi.fn(),
699
+ flushPrompt: vi
700
+ .fn()
701
+ .mockResolvedValue(makeFailedFlushResult("", "only stderr")),
702
+ }),
703
+ });
704
+
705
+ await pi.emit("session_start", {}, ctx);
706
+ await pi.emit("agent_end", {}, ctx);
707
+
708
+ const [message] = notify.mock.calls[0];
709
+ expect(message).not.toContain(" stdout:");
710
+ expect(message).toContain(" stderr:");
711
+ expect(message).toContain(" only stderr");
712
+ });
713
+
714
+ it("truncates a multi-kilobyte stderr while preserving the tail", async () => {
715
+ const pi = new TestPi();
716
+ const notify = vi.fn();
717
+ const ctx = createContext({ ui: { notify } });
718
+
719
+ // 200 lines, ~50 bytes each → ~10 KB. Cap well below.
720
+ const longStderr = Array.from(
721
+ { length: 200 },
722
+ (_, i) =>
723
+ `line${String(i).padStart(3, "0")}: ${"diagnostic-".repeat(3)}`,
724
+ ).join("\n");
725
+
726
+ createAutoformatExtension(pi.asExtensionAPI(), {
727
+ loadConfig: vi.fn().mockReturnValue({
728
+ ...createLoadResult(),
729
+ config: createFormatterConfig({
730
+ formatterOutput: {
731
+ onFailure: "stderr",
732
+ maxBytes: 1024,
733
+ maxLines: 100,
734
+ },
735
+ }),
736
+ }),
737
+ createAutoformatter: vi.fn().mockReturnValue({
738
+ recordToolResult: vi.fn(),
739
+ flushPrompt: vi.fn().mockResolvedValue({
740
+ groups: [
741
+ {
742
+ chain: ["prettier"],
743
+ files: ["/repo/a.ts"],
744
+ runs: [
745
+ {
746
+ formatterName: "prettier",
747
+ command: ["prettier", "--write", "/repo/a.ts"],
748
+ files: ["/repo/a.ts"],
749
+ success: false,
750
+ exitCode: 2,
751
+ stderr: longStderr,
752
+ },
753
+ ],
754
+ },
755
+ ],
756
+ }),
757
+ }),
758
+ });
759
+
760
+ await pi.emit("session_start", {}, ctx);
761
+ await pi.emit("agent_end", {}, ctx);
762
+
763
+ const [message] = notify.mock.calls[0];
764
+ expect(message).toMatch(/\(truncated, \d+ earlier (bytes|lines)\)/);
765
+ // Tail must survive.
766
+ expect(message).toContain("line199");
767
+ // Head must not.
768
+ expect(message).not.toContain("line000");
769
+ expect(message).not.toContain("line050");
770
+ });
771
+
772
+ it("never annotates successful runs even under onFailure: both", async () => {
773
+ const pi = new TestPi();
774
+ const notify = vi.fn();
775
+ const setStatus = vi.fn();
776
+ const ctx = createContext({
777
+ ui: {
778
+ notify,
779
+ setStatus,
780
+ theme: makeStubTheme(),
781
+ },
782
+ });
783
+
784
+ createAutoformatExtension(pi.asExtensionAPI(), {
785
+ loadConfig: vi.fn().mockReturnValue({
786
+ ...createLoadResult(),
787
+ config: createFormatterConfig({
788
+ formatterOutput: { onFailure: "both" },
789
+ }),
790
+ }),
791
+ createAutoformatter: vi.fn().mockReturnValue({
792
+ recordToolResult: vi.fn(),
793
+ flushPrompt: vi.fn().mockResolvedValue({
794
+ groups: [
795
+ {
796
+ chain: ["prettier"],
797
+ files: ["/repo/a.ts"],
798
+ runs: [
799
+ {
800
+ formatterName: "prettier",
801
+ command: ["prettier", "--write", "/repo/a.ts"],
802
+ files: ["/repo/a.ts"],
803
+ success: true,
804
+ exitCode: 0,
805
+ stdout: "would never be shown",
806
+ stderr: "deprecation notice",
807
+ },
808
+ ],
809
+ },
810
+ ],
811
+ }),
812
+ }),
813
+ });
814
+
815
+ await pi.emit("session_start", {}, ctx);
816
+ await pi.emit("agent_end", {}, ctx);
817
+
818
+ // No failure notify; success status only.
819
+ expect(notify).not.toHaveBeenCalled();
820
+ const statusTexts = setStatus.mock.calls
821
+ .map((c) => c[1])
822
+ .filter((t): t is string => typeof t === "string");
823
+ for (const text of statusTexts) {
824
+ expect(text).not.toContain("would never be shown");
825
+ expect(text).not.toContain("deprecation notice");
826
+ }
827
+ });
828
+ });
829
+
830
+ it("hides interactive success summaries when configured", async () => {
831
+ const pi = new TestPi();
832
+ const notify = vi.fn();
833
+ const setStatus = vi.fn();
834
+ const ctx = createContext({
835
+ ui: {
836
+ notify,
837
+ setStatus,
838
+ theme: makeStubTheme(),
839
+ },
840
+ });
841
+
842
+ createAutoformatExtension(pi.asExtensionAPI(), {
843
+ loadConfig: vi.fn().mockReturnValue({
844
+ ...createLoadResult(),
845
+ config: createFormatterConfig({
846
+ hideSummariesInTui: true,
847
+ }),
848
+ }),
849
+ createAutoformatter: vi.fn().mockReturnValue({
850
+ recordToolResult: vi.fn(),
851
+ flushPrompt: vi.fn().mockResolvedValue(createFlushResult()),
852
+ }),
853
+ });
854
+
855
+ await pi.emit("session_start", {}, ctx);
856
+ setStatus.mockClear();
857
+ await pi.emit("agent_end", {}, ctx);
858
+
859
+ expect(notify).not.toHaveBeenCalled();
860
+ expect(setStatus).toHaveBeenCalledWith("autoformat", undefined);
861
+ });
862
+
863
+ it("still surfaces failures when hideSummariesInTui is true", async () => {
864
+ const pi = new TestPi();
865
+ const notify = vi.fn();
866
+ const setStatus = vi.fn();
867
+ const ctx = createContext({
868
+ ui: {
869
+ notify,
870
+ setStatus,
871
+ theme: makeStubTheme(),
872
+ },
873
+ });
874
+
875
+ createAutoformatExtension(pi.asExtensionAPI(), {
876
+ loadConfig: vi.fn().mockReturnValue({
877
+ ...createLoadResult(),
878
+ config: createFormatterConfig({
879
+ hideSummariesInTui: true,
880
+ }),
881
+ }),
882
+ createAutoformatter: vi.fn().mockReturnValue({
883
+ recordToolResult: vi.fn(),
884
+ flushPrompt: vi.fn().mockResolvedValue({
885
+ groups: [
886
+ {
887
+ chain: ["prettier"],
888
+ files: ["/repo/a.ts"],
889
+ runs: [
890
+ {
891
+ formatterName: "prettier",
892
+ command: [],
893
+ files: ["/repo/a.ts"],
894
+ success: false,
895
+ exitCode: 2,
896
+ },
897
+ ],
898
+ },
899
+ ],
900
+ }),
901
+ }),
902
+ });
903
+
904
+ await pi.emit("session_start", {}, ctx);
905
+ setStatus.mockClear();
906
+ await pi.emit("agent_end", {}, ctx);
907
+
908
+ expect(notify).toHaveBeenCalledWith(
909
+ expect.stringContaining("prettier (exit 2): /repo/a.ts"),
910
+ "warning",
911
+ );
912
+ const failureStatus = setStatus.mock.calls.find((c) => c[1] !== undefined);
913
+ expect(failureStatus?.[1]).toContain("1 batch failed");
914
+ });
915
+
916
+ it("keeps non-interactive success summaries on console.log without setStatus", async () => {
917
+ const pi = new TestPi();
918
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
919
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
920
+ const setStatus = vi.fn();
921
+ const ctx: TestContext = {
922
+ cwd: "/repo",
923
+ hasUI: false,
924
+ ui: { notify: vi.fn(), setStatus },
925
+ };
926
+
927
+ createAutoformatExtension(pi.asExtensionAPI(), {
928
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
929
+ createAutoformatter: vi.fn().mockReturnValue({
930
+ recordToolResult: vi.fn(),
931
+ flushPrompt: vi.fn().mockResolvedValue({
932
+ groups: [
933
+ {
934
+ chain: ["prettier"],
935
+ files: ["/repo/a.ts"],
936
+ runs: [
937
+ {
938
+ formatterName: "prettier",
939
+ command: [],
940
+ files: ["/repo/a.ts"],
941
+ success: true,
942
+ exitCode: 0,
943
+ },
944
+ ],
945
+ },
946
+ ],
947
+ }),
948
+ }),
949
+ });
950
+
951
+ await pi.emit("session_start", {}, ctx);
952
+ await pi.emit("agent_end", {}, ctx);
953
+
954
+ expect(log).toHaveBeenCalledWith(
955
+ "[pi-autoformat] Autoformatted 1 file: /repo/a.ts",
956
+ );
957
+ expect(setStatus).not.toHaveBeenCalled();
958
+ expect(warn).not.toHaveBeenCalled();
959
+
960
+ log.mockRestore();
961
+ warn.mockRestore();
962
+ });
963
+
964
+ it("reports non-interactive formatter failures via console warnings", async () => {
965
+ const pi = new TestPi();
966
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
967
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
968
+ const ctx = createContext({ hasUI: false });
969
+
970
+ createAutoformatExtension(pi.asExtensionAPI(), {
971
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
972
+ createAutoformatter: vi.fn().mockReturnValue({
973
+ recordToolResult: vi.fn(),
974
+ flushPrompt: vi.fn().mockResolvedValue({
975
+ groups: [
976
+ {
977
+ chain: ["prettier", "markdownlint-cli2"],
978
+ files: ["/repo/README.md"],
979
+ runs: [
980
+ {
981
+ formatterName: "prettier",
982
+ command: ["prettier", "--write", "/repo/README.md"],
983
+ files: ["/repo/README.md"],
984
+ success: false,
985
+ exitCode: 2,
986
+ },
987
+ {
988
+ formatterName: "markdownlint-cli2",
989
+ command: ["markdownlint-cli2", "--fix", "/repo/README.md"],
990
+ files: ["/repo/README.md"],
991
+ success: false,
992
+ exitCode: 1,
993
+ },
994
+ ],
995
+ },
996
+ ],
997
+ }),
998
+ }),
999
+ });
1000
+
1001
+ await pi.emit("session_start", {}, ctx);
1002
+ await pi.emit("agent_end", {}, ctx);
1003
+
1004
+ expect(warn).toHaveBeenCalledWith(
1005
+ "[pi-autoformat] Formatter failures in 2 batches:\nprettier (exit 2): /repo/README.md\nmarkdownlint-cli2 (exit 1): /repo/README.md",
1006
+ );
1007
+ expect(log).not.toHaveBeenCalled();
1008
+ // Non-interactive contexts must never touch setStatus even when present.
1009
+ const setStatus = (ctx.ui as { setStatus?: ReturnType<typeof vi.fn> })
1010
+ .setStatus;
1011
+ expect(setStatus).toBeDefined();
1012
+ expect(setStatus).not.toHaveBeenCalled();
1013
+
1014
+ warn.mockRestore();
1015
+ log.mockRestore();
1016
+ });
1017
+
1018
+ it("reports non-interactive config issues via console warnings", async () => {
1019
+ const pi = new TestPi();
1020
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
1021
+ const ctx = createContext({ hasUI: false });
1022
+
1023
+ createAutoformatExtension(pi.asExtensionAPI(), {
1024
+ loadConfig: vi.fn().mockReturnValue({
1025
+ ...createLoadResult(),
1026
+ issues: [
1027
+ {
1028
+ path: "commandTimeoutMs",
1029
+ message: "Expected a positive integer.",
1030
+ sourcePath: "/repo/.pi/extensions/pi-autoformat/config.json",
1031
+ },
1032
+ ],
1033
+ }),
1034
+ createAutoformatter: vi.fn().mockReturnValue({
1035
+ recordToolResult: vi.fn(),
1036
+ flushPrompt: vi.fn().mockResolvedValue({ groups: [] }),
1037
+ }),
1038
+ });
1039
+
1040
+ await pi.emit("session_start", {}, ctx);
1041
+
1042
+ expect(warn).toHaveBeenCalledWith(
1043
+ "[pi-autoformat] Configuration issues detected:\n/repo/.pi/extensions/pi-autoformat/config.json commandTimeoutMs: Expected a positive integer.",
1044
+ );
1045
+
1046
+ warn.mockRestore();
1047
+ });
1048
+
1049
+ it("clears the autoformat status on session_start and session_shutdown", async () => {
1050
+ const pi = new TestPi();
1051
+ const setStatus = vi.fn();
1052
+ const ctx = createContext({
1053
+ ui: {
1054
+ notify: vi.fn(),
1055
+ setStatus,
1056
+ theme: makeStubTheme(),
1057
+ },
1058
+ });
1059
+
1060
+ createAutoformatExtension(pi.asExtensionAPI(), {
1061
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1062
+ createAutoformatter: vi.fn().mockReturnValue({
1063
+ recordToolResult: vi.fn(),
1064
+ flushPrompt: vi.fn().mockResolvedValue({ groups: [] }),
1065
+ }),
1066
+ });
1067
+
1068
+ await pi.emit("session_start", {}, ctx);
1069
+ expect(setStatus).toHaveBeenCalledWith("autoformat", undefined);
1070
+
1071
+ setStatus.mockClear();
1072
+ await pi.emit("session_shutdown", {}, ctx);
1073
+ expect(setStatus).toHaveBeenCalledWith("autoformat", undefined);
1074
+ });
1075
+
1076
+ it("records successful tool results and flushes at prompt end in prompt mode", async () => {
1077
+ const pi = new TestPi();
1078
+ const ctx = createContext();
1079
+ const autoformatter = {
1080
+ recordToolResult: vi.fn(),
1081
+ flushPrompt: vi.fn().mockResolvedValue(createFlushResult()),
1082
+ };
1083
+ const reportFlushResult = vi.fn();
1084
+
1085
+ createAutoformatExtension(pi.asExtensionAPI(), {
1086
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1087
+ createAutoformatter: vi.fn().mockReturnValue(autoformatter),
1088
+ reportFlushResult,
1089
+ });
1090
+
1091
+ await pi.emit("session_start", {}, ctx);
1092
+ await pi.emit(
1093
+ "tool_result",
1094
+ {
1095
+ toolName: "write",
1096
+ input: { path: "src/example.ts", content: "export {};" },
1097
+ isError: false,
1098
+ },
1099
+ ctx,
1100
+ );
1101
+ await pi.emit("agent_end", {}, ctx);
1102
+
1103
+ expect(autoformatter.recordToolResult).toHaveBeenCalledWith(
1104
+ "write",
1105
+ {
1106
+ path: "src/example.ts",
1107
+ content: "export {};",
1108
+ },
1109
+ "",
1110
+ );
1111
+ expect(autoformatter.flushPrompt).toHaveBeenCalledTimes(1);
1112
+ expect(reportFlushResult).toHaveBeenCalledTimes(1);
1113
+ });
1114
+
1115
+ it("ignores failed tool results", async () => {
1116
+ const pi = new TestPi();
1117
+ const ctx = createContext();
1118
+ const autoformatter = {
1119
+ recordToolResult: vi.fn(),
1120
+ flushPrompt: vi.fn().mockResolvedValue({ groups: [] }),
1121
+ };
1122
+
1123
+ createAutoformatExtension(pi.asExtensionAPI(), {
1124
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1125
+ createAutoformatter: vi.fn().mockReturnValue(autoformatter),
1126
+ reportFlushResult: vi.fn(),
1127
+ });
1128
+
1129
+ await pi.emit("session_start", {}, ctx);
1130
+ await pi.emit(
1131
+ "tool_result",
1132
+ {
1133
+ toolName: "write",
1134
+ input: { path: "src/example.ts", content: "" },
1135
+ isError: true,
1136
+ },
1137
+ ctx,
1138
+ );
1139
+
1140
+ expect(autoformatter.recordToolResult).not.toHaveBeenCalled();
1141
+ });
1142
+
1143
+ it("forwards bash tool output to the autoformatter", async () => {
1144
+ const pi = new TestPi();
1145
+ const ctx = createContext();
1146
+ const autoformatter = {
1147
+ recordToolResult: vi.fn(),
1148
+ flushPrompt: vi.fn().mockResolvedValue({ groups: [] }),
1149
+ addTouchedPath: vi.fn(),
1150
+ };
1151
+
1152
+ createAutoformatExtension(pi.asExtensionAPI(), {
1153
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1154
+ createAutoformatter: vi.fn().mockReturnValue(autoformatter),
1155
+ reportFlushResult: vi.fn(),
1156
+ });
1157
+
1158
+ await pi.emit(
1159
+ "tool_result",
1160
+ {
1161
+ toolName: "bash",
1162
+ input: { command: "sed -i 's/a/b/' foo.txt" },
1163
+ isError: false,
1164
+ content: [{ type: "text", text: "some output" }],
1165
+ },
1166
+ ctx,
1167
+ );
1168
+
1169
+ expect(autoformatter.recordToolResult).toHaveBeenCalledWith(
1170
+ "bash",
1171
+ { command: "sed -i 's/a/b/' foo.txt" },
1172
+ "some output",
1173
+ );
1174
+ });
1175
+
1176
+ it("reports config issues on session start", async () => {
1177
+ const pi = new TestPi();
1178
+ const ctx = createContext();
1179
+ const reportConfigIssues = vi.fn();
1180
+
1181
+ createAutoformatExtension(pi.asExtensionAPI(), {
1182
+ loadConfig: vi.fn().mockReturnValue({
1183
+ ...createLoadResult(),
1184
+ issues: [
1185
+ {
1186
+ path: "commandTimeoutMs",
1187
+ message: "Expected a positive integer.",
1188
+ sourcePath: "/repo/.pi/extensions/pi-autoformat/config.json",
1189
+ },
1190
+ ],
1191
+ }),
1192
+ createAutoformatter: vi.fn().mockReturnValue({
1193
+ recordToolResult: vi.fn(),
1194
+ flushPrompt: vi.fn().mockResolvedValue({ groups: [] }),
1195
+ }),
1196
+ reportConfigIssues,
1197
+ });
1198
+
1199
+ await pi.emit("session_start", {}, ctx);
1200
+
1201
+ expect(reportConfigIssues).toHaveBeenCalledWith(
1202
+ [
1203
+ {
1204
+ path: "commandTimeoutMs",
1205
+ message: "Expected a positive integer.",
1206
+ sourcePath: "/repo/.pi/extensions/pi-autoformat/config.json",
1207
+ },
1208
+ ],
1209
+ { ctx },
1210
+ );
1211
+ });
1212
+
1213
+ it("subscribes to the configured EventBus channel and forwards touched paths", async () => {
1214
+ const pi = new TestPi();
1215
+ const ctx = createContext();
1216
+ const addTouchedPath = vi.fn();
1217
+ const autoformatter = {
1218
+ recordToolResult: vi.fn(),
1219
+ flushPrompt: vi.fn().mockResolvedValue({ groups: [] }),
1220
+ addTouchedPath,
1221
+ };
1222
+
1223
+ createAutoformatExtension(pi.asExtensionAPI(), {
1224
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1225
+ createAutoformatter: vi.fn().mockReturnValue(autoformatter),
1226
+ reportFlushResult: vi.fn(),
1227
+ });
1228
+
1229
+ await pi.emit("session_start", {}, ctx);
1230
+ expect(pi.busHandlerCount("autoformat:touched")).toBe(1);
1231
+
1232
+ pi.emitBus("autoformat:touched", { path: "src/a.ts" });
1233
+ pi.emitBus("autoformat:touched", {
1234
+ paths: ["src/b.ts", "src/c.ts"],
1235
+ });
1236
+ pi.emitBus("autoformat:touched", "not-an-object");
1237
+
1238
+ expect(addTouchedPath.mock.calls.map((c) => c[0])).toEqual([
1239
+ "src/a.ts",
1240
+ "src/b.ts",
1241
+ "src/c.ts",
1242
+ ]);
1243
+
1244
+ await pi.emit("session_shutdown", {}, ctx);
1245
+ expect(pi.busHandlerCount("autoformat:touched")).toBe(0);
1246
+ });
1247
+
1248
+ it("does not subscribe when eventBusMutationChannel.enabled is false", async () => {
1249
+ const pi = new TestPi();
1250
+ const ctx = createContext();
1251
+
1252
+ createAutoformatExtension(pi.asExtensionAPI(), {
1253
+ loadConfig: vi.fn().mockReturnValue({
1254
+ ...createLoadResult(),
1255
+ config: createFormatterConfig({
1256
+ eventBusMutationChannel: { enabled: false },
1257
+ }),
1258
+ }),
1259
+ createAutoformatter: vi.fn().mockReturnValue({
1260
+ recordToolResult: vi.fn(),
1261
+ flushPrompt: vi.fn().mockResolvedValue({ groups: [] }),
1262
+ addTouchedPath: vi.fn(),
1263
+ }),
1264
+ reportFlushResult: vi.fn(),
1265
+ });
1266
+
1267
+ await pi.emit("session_start", {}, ctx);
1268
+ expect(pi.busHandlerCount("autoformat:touched")).toBe(0);
1269
+ });
1270
+
1271
+ it("respects a custom EventBus channel name", async () => {
1272
+ const pi = new TestPi();
1273
+ const ctx = createContext();
1274
+ const addTouchedPath = vi.fn();
1275
+
1276
+ createAutoformatExtension(pi.asExtensionAPI(), {
1277
+ loadConfig: vi.fn().mockReturnValue({
1278
+ ...createLoadResult(),
1279
+ config: createFormatterConfig({
1280
+ eventBusMutationChannel: { channel: "my:channel" },
1281
+ }),
1282
+ }),
1283
+ createAutoformatter: vi.fn().mockReturnValue({
1284
+ recordToolResult: vi.fn(),
1285
+ flushPrompt: vi.fn().mockResolvedValue({ groups: [] }),
1286
+ addTouchedPath,
1287
+ }),
1288
+ reportFlushResult: vi.fn(),
1289
+ });
1290
+
1291
+ await pi.emit("session_start", {}, ctx);
1292
+ pi.emitBus("my:channel", { path: "src/x.ts" });
1293
+ expect(addTouchedPath).toHaveBeenCalledWith("src/x.ts");
1294
+ });
1295
+
1296
+ it("wires customMutationTools into the default autoformatter queue", async () => {
1297
+ const config = createFormatterConfig({
1298
+ customMutationTools: [{ toolName: "my-codegen", pathField: "output" }],
1299
+ formatters: {
1300
+ "echo-fmt": {
1301
+ command: ["true"],
1302
+ },
1303
+ },
1304
+ chains: {
1305
+ ".ts": ["echo-fmt"],
1306
+ },
1307
+ });
1308
+ const autoformatter = createDefaultAutoformatter("/repo", config);
1309
+
1310
+ autoformatter.recordToolResult(
1311
+ "my-codegen",
1312
+ { output: "src/generated.ts" },
1313
+ "",
1314
+ );
1315
+ const result = await autoformatter.flushPrompt();
1316
+
1317
+ expect(result.groups.flatMap((g) => g.files)).toEqual([
1318
+ "/repo/src/generated.ts",
1319
+ ]);
1320
+ });
1321
+
1322
+ it("does not send a follow-up message from agent_end", async () => {
1323
+ const pi = new TestPi();
1324
+ const ctx = createContext();
1325
+
1326
+ createAutoformatExtension(pi.asExtensionAPI(), {
1327
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1328
+ createAutoformatter: vi.fn().mockReturnValue({
1329
+ recordToolResult: vi.fn(),
1330
+ flushPrompt: vi.fn().mockResolvedValue(createFlushResult()),
1331
+ addTouchedPath: vi.fn(),
1332
+ }),
1333
+ });
1334
+
1335
+ await pi.emit("session_start", {}, ctx);
1336
+ await pi.emit("agent_end", {}, ctx);
1337
+
1338
+ // agent_end should NOT send messages (steering is at turn_end only)
1339
+ expect(pi.sentMessages).toHaveLength(0);
1340
+ });
1341
+
1342
+ it("flushes formatters at turn_end", async () => {
1343
+ const pi = new TestPi();
1344
+ const ctx = createContext();
1345
+ const autoformatter = {
1346
+ recordToolResult: vi.fn(),
1347
+ flushPrompt: vi.fn().mockResolvedValue(createFlushResult()),
1348
+ addTouchedPath: vi.fn(),
1349
+ };
1350
+ const reportFlushResult = vi.fn();
1351
+
1352
+ createAutoformatExtension(pi.asExtensionAPI(), {
1353
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1354
+ createAutoformatter: vi.fn().mockReturnValue(autoformatter),
1355
+ reportFlushResult,
1356
+ });
1357
+
1358
+ await pi.emit("session_start", {}, ctx);
1359
+ await pi.emit(
314
1360
  "tool_result",
315
1361
  {
316
1362
  toolName: "write",
317
- input: { path: "src/example.ts", content: "" },
318
- isError: true,
1363
+ input: { path: "src/example.ts", content: "export {};" },
1364
+ isError: false,
319
1365
  },
320
1366
  ctx,
321
1367
  );
322
- await pi.emit("session_shutdown", {}, ctx);
1368
+ await pi.emit("turn_end", {}, ctx);
323
1369
 
324
- expect(autoformatter.recordToolResult).not.toHaveBeenCalled();
325
1370
  expect(autoformatter.flushPrompt).toHaveBeenCalledTimes(1);
1371
+ expect(reportFlushResult).toHaveBeenCalledTimes(1);
326
1372
  });
327
1373
 
328
- it("reports config issues on session start", async () => {
1374
+ it("sends a steering message when turn_end flush changes files", async () => {
329
1375
  const pi = new TestPi();
330
1376
  const ctx = createContext();
331
- const reportConfigIssues = vi.fn();
332
1377
 
333
- createAutoformatExtension(pi, {
334
- loadConfig: vi.fn().mockReturnValue({
335
- ...createLoadResult("prompt"),
336
- issues: [
337
- {
338
- path: "formatMode",
339
- message: "Expected a valid mode.",
340
- sourcePath: "/repo/.pi/extensions/pi-autoformat/config.json",
341
- },
342
- ],
1378
+ createAutoformatExtension(pi.asExtensionAPI(), {
1379
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1380
+ createAutoformatter: vi.fn().mockReturnValue({
1381
+ recordToolResult: vi.fn(),
1382
+ flushPrompt: vi.fn().mockResolvedValue({
1383
+ groups: [
1384
+ {
1385
+ chain: ["prettier"],
1386
+ files: ["/repo/src/foo.ts"],
1387
+ runs: [
1388
+ {
1389
+ formatterName: "prettier",
1390
+ command: ["prettier", "--write"],
1391
+ files: ["/repo/src/foo.ts"],
1392
+ success: true,
1393
+ exitCode: 0,
1394
+ },
1395
+ ],
1396
+ changedFiles: ["/repo/src/foo.ts"],
1397
+ },
1398
+ ],
1399
+ }),
1400
+ addTouchedPath: vi.fn(),
343
1401
  }),
1402
+ });
1403
+
1404
+ await pi.emit("session_start", {}, ctx);
1405
+ await pi.emit("turn_end", {}, ctx);
1406
+
1407
+ expect(pi.sentMessages).toHaveLength(1);
1408
+ const content = pi.sentMessages[0].message.content as string;
1409
+ expect(content).toContain("[autoformat] Formatted 1 file(s)");
1410
+ expect(content).toContain("/repo/src/foo.ts");
1411
+ });
1412
+
1413
+ it("does not send a steering message when turn_end flush has no changes and no failures", async () => {
1414
+ const pi = new TestPi();
1415
+ const ctx = createContext();
1416
+
1417
+ createAutoformatExtension(pi.asExtensionAPI(), {
1418
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
344
1419
  createAutoformatter: vi.fn().mockReturnValue({
345
1420
  recordToolResult: vi.fn(),
346
- flushPrompt: vi.fn().mockResolvedValue({ files: [] }),
1421
+ flushPrompt: vi.fn().mockResolvedValue({
1422
+ groups: [
1423
+ {
1424
+ chain: ["prettier"],
1425
+ files: ["/repo/src/foo.ts"],
1426
+ runs: [
1427
+ {
1428
+ formatterName: "prettier",
1429
+ command: ["prettier", "--write"],
1430
+ files: ["/repo/src/foo.ts"],
1431
+ success: true,
1432
+ exitCode: 0,
1433
+ },
1434
+ ],
1435
+ changedFiles: [],
1436
+ },
1437
+ ],
1438
+ }),
1439
+ addTouchedPath: vi.fn(),
347
1440
  }),
348
- reportConfigIssues,
349
1441
  });
350
1442
 
351
1443
  await pi.emit("session_start", {}, ctx);
1444
+ await pi.emit("turn_end", {}, ctx);
352
1445
 
353
- expect(reportConfigIssues).toHaveBeenCalledWith(
354
- [
1446
+ expect(pi.sentMessages).toHaveLength(0);
1447
+ });
1448
+
1449
+ it("sends a steering message with failure details on formatter failure", async () => {
1450
+ const pi = new TestPi();
1451
+ const ctx = createContext();
1452
+
1453
+ createAutoformatExtension(pi.asExtensionAPI(), {
1454
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1455
+ createAutoformatter: vi.fn().mockReturnValue({
1456
+ recordToolResult: vi.fn(),
1457
+ flushPrompt: vi.fn().mockResolvedValue({
1458
+ groups: [
1459
+ {
1460
+ chain: ["biome"],
1461
+ files: ["/repo/src/broken.ts"],
1462
+ runs: [
1463
+ {
1464
+ formatterName: "biome",
1465
+ command: ["biome", "format", "--write"],
1466
+ files: ["/repo/src/broken.ts"],
1467
+ success: false,
1468
+ exitCode: 1,
1469
+ stderr: "SyntaxError: Unexpected token at line 42",
1470
+ },
1471
+ ],
1472
+ changedFiles: [],
1473
+ },
1474
+ ],
1475
+ }),
1476
+ addTouchedPath: vi.fn(),
1477
+ }),
1478
+ });
1479
+
1480
+ await pi.emit("session_start", {}, ctx);
1481
+ await pi.emit("turn_end", {}, ctx);
1482
+
1483
+ expect(pi.sentMessages).toHaveLength(1);
1484
+ const content = pi.sentMessages[0].message.content as string;
1485
+ expect(content).toContain("Failures:");
1486
+ expect(content).toContain("biome (exit 1) on /repo/src/broken.ts");
1487
+ expect(content).toContain("SyntaxError: Unexpected token at line 42");
1488
+ });
1489
+
1490
+ it("does not send a steering message on empty flush", async () => {
1491
+ const pi = new TestPi();
1492
+ const ctx = createContext();
1493
+
1494
+ createAutoformatExtension(pi.asExtensionAPI(), {
1495
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1496
+ createAutoformatter: vi.fn().mockReturnValue({
1497
+ recordToolResult: vi.fn(),
1498
+ flushPrompt: vi.fn().mockResolvedValue({ groups: [] }),
1499
+ addTouchedPath: vi.fn(),
1500
+ }),
1501
+ });
1502
+
1503
+ await pi.emit("session_start", {}, ctx);
1504
+ await pi.emit("turn_end", {}, ctx);
1505
+
1506
+ expect(pi.sentMessages).toHaveLength(0);
1507
+ });
1508
+
1509
+ it("formats EventBus-sourced files at agent_end as safety net", async () => {
1510
+ const pi = new TestPi();
1511
+ const ctx = createContext();
1512
+ const reportFlushResult = vi.fn();
1513
+ let flushCallCount = 0;
1514
+
1515
+ createAutoformatExtension(pi.asExtensionAPI(), {
1516
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1517
+ createAutoformatter: vi.fn().mockReturnValue({
1518
+ recordToolResult: vi.fn(),
1519
+ flushPrompt: vi.fn().mockImplementation(() => {
1520
+ flushCallCount += 1;
1521
+ // First flush (turn_end) is empty; second (agent_end) has work
1522
+ if (flushCallCount === 1) {
1523
+ return Promise.resolve({ groups: [] });
1524
+ }
1525
+ return Promise.resolve(createFlushResult());
1526
+ }),
1527
+ addTouchedPath: vi.fn(),
1528
+ }),
1529
+ reportFlushResult,
1530
+ });
1531
+
1532
+ await pi.emit("session_start", {}, ctx);
1533
+ // Simulate EventBus file added after turn_end
1534
+ await pi.emit("turn_end", {}, ctx);
1535
+ // Now agent_end flushes the EventBus-sourced file
1536
+ await pi.emit("agent_end", {}, ctx);
1537
+
1538
+ expect(reportFlushResult).toHaveBeenCalledTimes(2);
1539
+ // Second call has groups (from the safety-net flush)
1540
+ const secondResult = reportFlushResult.mock.calls[1][0];
1541
+ expect(secondResult.groups.length).toBeGreaterThan(0);
1542
+ });
1543
+
1544
+ it("does not re-flush at agent_end after turn_end already flushed", async () => {
1545
+ const pi = new TestPi();
1546
+ const ctx = createContext();
1547
+ const autoformatter = {
1548
+ recordToolResult: vi.fn(),
1549
+ flushPrompt: vi.fn().mockResolvedValue(createFlushResult()),
1550
+ addTouchedPath: vi.fn(),
1551
+ };
1552
+ const reportFlushResult = vi.fn();
1553
+
1554
+ createAutoformatExtension(pi.asExtensionAPI(), {
1555
+ loadConfig: vi.fn().mockReturnValue(createLoadResult()),
1556
+ createAutoformatter: vi.fn().mockReturnValue(autoformatter),
1557
+ reportFlushResult,
1558
+ });
1559
+
1560
+ await pi.emit("session_start", {}, ctx);
1561
+ await pi.emit(
1562
+ "tool_result",
1563
+ {
1564
+ toolName: "write",
1565
+ input: { path: "src/example.ts", content: "export {};" },
1566
+ isError: false,
1567
+ },
1568
+ ctx,
1569
+ );
1570
+ await pi.emit("turn_end", {}, ctx);
1571
+ reportFlushResult.mockClear();
1572
+ autoformatter.flushPrompt.mockClear();
1573
+
1574
+ // agent_end calls flush but queue is already empty
1575
+ autoformatter.flushPrompt.mockResolvedValue({ groups: [] });
1576
+ await pi.emit("agent_end", {}, ctx);
1577
+
1578
+ expect(autoformatter.flushPrompt).toHaveBeenCalledTimes(1);
1579
+ // The second flush should produce an empty result
1580
+ });
1581
+ });
1582
+
1583
+ describe("buildSteeringMessageContent", () => {
1584
+ it("returns undefined when no changedFiles and no failures", () => {
1585
+ const result = buildSteeringMessageContent({
1586
+ groups: [
355
1587
  {
356
- path: "formatMode",
357
- message: "Expected a valid mode.",
358
- sourcePath: "/repo/.pi/extensions/pi-autoformat/config.json",
1588
+ chain: ["prettier"],
1589
+ files: ["/repo/a.ts"],
1590
+ runs: [
1591
+ {
1592
+ formatterName: "prettier",
1593
+ command: ["prettier", "--write"],
1594
+ files: ["/repo/a.ts"],
1595
+ success: true,
1596
+ exitCode: 0,
1597
+ },
1598
+ ],
1599
+ changedFiles: [],
359
1600
  },
360
1601
  ],
361
- { ctx },
362
- );
1602
+ });
1603
+ expect(result).toBeUndefined();
1604
+ });
1605
+
1606
+ it("returns undefined for empty groups", () => {
1607
+ expect(buildSteeringMessageContent({ groups: [] })).toBeUndefined();
1608
+ });
1609
+
1610
+ it("lists a single changed file", () => {
1611
+ const result = buildSteeringMessageContent({
1612
+ groups: [
1613
+ {
1614
+ chain: ["prettier"],
1615
+ files: ["/repo/src/foo.ts"],
1616
+ runs: [
1617
+ {
1618
+ formatterName: "prettier",
1619
+ command: ["prettier", "--write"],
1620
+ files: ["/repo/src/foo.ts"],
1621
+ success: true,
1622
+ exitCode: 0,
1623
+ },
1624
+ ],
1625
+ changedFiles: ["/repo/src/foo.ts"],
1626
+ },
1627
+ ],
1628
+ });
1629
+ expect(result).toContain("[autoformat] Formatted 1 file(s)");
1630
+ expect(result).toContain("/repo/src/foo.ts");
1631
+ });
1632
+
1633
+ it("lists three changed files", () => {
1634
+ const result = buildSteeringMessageContent({
1635
+ groups: [
1636
+ {
1637
+ chain: ["prettier"],
1638
+ files: ["/repo/a.ts", "/repo/b.ts", "/repo/c.ts"],
1639
+ runs: [
1640
+ {
1641
+ formatterName: "prettier",
1642
+ command: [],
1643
+ files: ["/repo/a.ts", "/repo/b.ts", "/repo/c.ts"],
1644
+ success: true,
1645
+ exitCode: 0,
1646
+ },
1647
+ ],
1648
+ changedFiles: ["/repo/a.ts", "/repo/b.ts", "/repo/c.ts"],
1649
+ },
1650
+ ],
1651
+ });
1652
+ expect(result).toContain("3 file(s)");
1653
+ expect(result).toContain("/repo/a.ts");
1654
+ expect(result).toContain("/repo/b.ts");
1655
+ expect(result).toContain("/repo/c.ts");
1656
+ });
1657
+
1658
+ it("truncates file lists beyond 10 files", () => {
1659
+ const changedFiles = Array.from({ length: 11 }, (_, i) => `/repo/f${i}.ts`);
1660
+ const result = buildSteeringMessageContent({
1661
+ groups: [
1662
+ {
1663
+ chain: ["prettier"],
1664
+ files: changedFiles,
1665
+ runs: [
1666
+ {
1667
+ formatterName: "prettier",
1668
+ command: [],
1669
+ files: changedFiles,
1670
+ success: true,
1671
+ exitCode: 0,
1672
+ },
1673
+ ],
1674
+ changedFiles,
1675
+ },
1676
+ ],
1677
+ });
1678
+ expect(result).toContain("11 file(s)");
1679
+ expect(result).toContain("/repo/f9.ts");
1680
+ expect(result).not.toContain("/repo/f10.ts");
1681
+ expect(result).toContain("and 1 more");
1682
+ });
1683
+
1684
+ it("includes failure details with stderr", () => {
1685
+ const result = buildSteeringMessageContent({
1686
+ groups: [
1687
+ {
1688
+ chain: ["prettier"],
1689
+ files: ["/repo/bad.ts"],
1690
+ runs: [
1691
+ {
1692
+ formatterName: "prettier",
1693
+ command: ["prettier", "--write"],
1694
+ files: ["/repo/bad.ts"],
1695
+ success: false,
1696
+ exitCode: 2,
1697
+ stderr: "SyntaxError: Unexpected token at line 42",
1698
+ },
1699
+ ],
1700
+ changedFiles: [],
1701
+ },
1702
+ ],
1703
+ });
1704
+ expect(result).toContain("Failures:");
1705
+ expect(result).toContain("prettier (exit 2) on /repo/bad.ts");
1706
+ expect(result).toContain("SyntaxError: Unexpected token at line 42");
1707
+ });
1708
+
1709
+ it("includes both changed files and failures", () => {
1710
+ const result = buildSteeringMessageContent({
1711
+ groups: [
1712
+ {
1713
+ chain: ["prettier"],
1714
+ files: ["/repo/ok.ts", "/repo/bad.ts"],
1715
+ runs: [
1716
+ {
1717
+ formatterName: "prettier",
1718
+ command: ["prettier", "--write"],
1719
+ files: ["/repo/ok.ts"],
1720
+ success: true,
1721
+ exitCode: 0,
1722
+ },
1723
+ {
1724
+ formatterName: "prettier",
1725
+ command: ["prettier", "--write"],
1726
+ files: ["/repo/bad.ts"],
1727
+ success: false,
1728
+ exitCode: 2,
1729
+ stderr: "SyntaxError",
1730
+ },
1731
+ ],
1732
+ changedFiles: ["/repo/ok.ts"],
1733
+ },
1734
+ ],
1735
+ });
1736
+ expect(result).toContain("[autoformat] Formatted 1 file(s)");
1737
+ expect(result).toContain("/repo/ok.ts");
1738
+ expect(result).toContain("Failures:");
1739
+ expect(result).toContain("prettier (exit 2)");
363
1740
  });
364
1741
  });