@mrclrchtr/supi-review 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,12 @@
1
1
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
- import type { ReviewProgress } from "../types.ts";
2
+ import type { ChildFailureCode, ChildFailureDiagnostics, ReviewProgress } from "../types.ts";
3
+ import { buildChildFailureDiagnostics } from "./child-failure-diagnostics.ts";
4
+ import {
5
+ type ChildLifecycleHostMarker,
6
+ type ChildLifecycleTrace,
7
+ ChildLifecycleTraceCollector,
8
+ getRegisteredToolNames,
9
+ } from "./child-lifecycle-trace.ts";
3
10
 
4
11
  /**
5
12
  * Context passed to event handlers, timeout callbacks, and result factories.
@@ -22,6 +29,12 @@ export interface LifecycleCtx<TResult> {
22
29
  state: { settled: boolean; aborting: boolean };
23
30
  /** The managed agent session. */
24
31
  session: AgentSession;
32
+ /** Snapshot the bounded Child Lifecycle Trace observed so far. */
33
+ getLifecycleTrace: () => ChildLifecycleTrace;
34
+ /** Build a safe non-success diagnostic artifact from the observed child run. */
35
+ getFailureDiagnostics: () => ChildFailureDiagnostics;
36
+ /** Add one allowlisted runner-control marker to the Child Lifecycle Trace. */
37
+ recordHostMarker: (marker: ChildLifecycleHostMarker) => void;
25
38
  /**
26
39
  * Register a teardown function that runs when cleanup is called.
27
40
  * Useful for custom timers or resources set up by `onTimeout`.
@@ -55,12 +68,35 @@ export interface RunWithLifecycleConfig<TResult> {
55
68
  onTimeout?: (ctx: LifecycleCtx<TResult>) => void;
56
69
  /** Factory for the result produced when the abort signal fires. */
57
70
  canceledResult: (ctx: LifecycleCtx<TResult>) => TResult;
58
- /** Factory for the result produced when `session.prompt()` throws. */
59
- failedResult: (reason: string, ctx: LifecycleCtx<TResult>) => TResult;
71
+ /** Factory for the result produced when prompt preflight or execution fails. */
72
+ failedResult: (
73
+ code: Extract<ChildFailureCode, "prompt-rejected" | "unexpected-runner-failure">,
74
+ ctx: LifecycleCtx<TResult>,
75
+ ) => TResult;
60
76
  /** Factory for the result produced when the timeout expires (default hard abort). */
61
77
  timeoutResult: (timeoutMs: number, ctx: LifecycleCtx<TResult>) => TResult;
62
78
  }
63
79
 
80
+ interface StartPromptOptions {
81
+ session: AgentSession;
82
+ prompt: string;
83
+ onPreflightRejected: () => void;
84
+ onFulfilled: () => void;
85
+ onUnexpectedFailure: () => void;
86
+ }
87
+
88
+ function startPrompt(options: StartPromptOptions): void {
89
+ const { session, prompt, onPreflightRejected, onFulfilled, onUnexpectedFailure } = options;
90
+ try {
91
+ const promptPromise = session.prompt(prompt, {
92
+ preflightResult: (accepted) => !accepted && onPreflightRejected(),
93
+ });
94
+ void promptPromise.then(onFulfilled, onUnexpectedFailure);
95
+ } catch {
96
+ onUnexpectedFailure();
97
+ }
98
+ }
99
+
64
100
  /**
65
101
  * Manages the lifecycle of a child agent session: subscribes to events,
66
102
  * wires abort-signal handling, enforces a timeout, and provides idempotent
@@ -74,6 +110,7 @@ export interface RunWithLifecycleConfig<TResult> {
74
110
  * it by calling `ctx.resolve(ctx.cleanup(...))` itself)
75
111
  * - `session.prompt()` rejects (resolves via `failedResult`)
76
112
  */
113
+ // biome-ignore lint/complexity/noExcessiveLinesPerFunction: lifecycle ownership must remain in one state-machine closure
77
114
  export function runWithLifecycle<TResult>(
78
115
  config: RunWithLifecycleConfig<TResult>,
79
116
  ): Promise<TResult> {
@@ -99,6 +136,7 @@ export function runWithLifecycle<TResult>(
99
136
  aborting: false,
100
137
  };
101
138
  const teardownFns: (() => void)[] = [];
139
+ const lifecycleTrace = new ChildLifecycleTraceCollector(getRegisteredToolNames(session));
102
140
 
103
141
  const cancelTeardown = (): void => {
104
142
  for (const fn of teardownFns) {
@@ -132,18 +170,53 @@ export function runWithLifecycle<TResult>(
132
170
  progress,
133
171
  state,
134
172
  session,
173
+ getLifecycleTrace: () => lifecycleTrace.snapshot(),
174
+ getFailureDiagnostics: () =>
175
+ buildChildFailureDiagnostics({
176
+ progress,
177
+ session,
178
+ lifecycleTrace: lifecycleTrace.snapshot(),
179
+ recentActivity: lifecycleTrace.recentActivitySnapshot(),
180
+ }),
181
+ recordHostMarker: (marker) => lifecycleTrace.recordHostMarker(marker),
135
182
  addTeardown,
136
183
  startTime,
137
184
  };
138
185
 
186
+ let promptSettled = false;
187
+ let deferredAgentSettled: Extract<AgentSessionEvent, { type: "agent_settled" }> | undefined;
188
+
189
+ const dispatchEvent = (event: AgentSessionEvent): void => {
190
+ try {
191
+ onEvent(event, ctx);
192
+ } catch {
193
+ if (!state.settled && !state.aborting) {
194
+ resolve(cleanup(failedResult("unexpected-runner-failure", ctx)));
195
+ }
196
+ }
197
+ };
198
+
199
+ const flushDeferredSettlement = (): void => {
200
+ if (!deferredAgentSettled || state.settled || state.aborting) return;
201
+ const event = deferredAgentSettled;
202
+ deferredAgentSettled = undefined;
203
+ dispatchEvent(event);
204
+ };
205
+
139
206
  session.subscribe((event: AgentSessionEvent) => {
140
- onEvent(event, ctx);
207
+ lifecycleTrace.observe(event);
208
+ if (event.type === "agent_settled" && !promptSettled) {
209
+ deferredAgentSettled = event;
210
+ return;
211
+ }
212
+ dispatchEvent(event);
141
213
  });
142
214
 
143
215
  // Abort signal handler
144
216
  const onAbort = () => {
145
217
  if (state.settled || state.aborting) return;
146
218
  state.aborting = true;
219
+ ctx.recordHostMarker({ type: "abort_requested", reason: "canceled" });
147
220
  void session
148
221
  .abort()
149
222
  .catch(() => {})
@@ -163,12 +236,18 @@ export function runWithLifecycle<TResult>(
163
236
  // Timeout handler
164
237
  const onTimeoutExpired = () => {
165
238
  if (state.settled || state.aborting) return;
239
+ ctx.recordHostMarker({ type: "timeout_expired" });
166
240
 
167
241
  if (onTimeout) {
168
- onTimeout(ctx);
242
+ try {
243
+ onTimeout(ctx);
244
+ } catch {
245
+ resolve(cleanup(failedResult("unexpected-runner-failure", ctx)));
246
+ }
169
247
  } else {
170
248
  // Default: hard abort
171
249
  state.aborting = true;
250
+ ctx.recordHostMarker({ type: "abort_requested", reason: "timeout" });
172
251
  void session
173
252
  .abort()
174
253
  .catch(() => {})
@@ -181,11 +260,27 @@ export function runWithLifecycle<TResult>(
181
260
  timeoutId.unref?.();
182
261
  addTeardown(() => clearTimeout(timeoutId));
183
262
 
184
- // Start the session
185
- session.prompt(prompt).catch((error: unknown) => {
186
- if (!state.settled) {
187
- resolve(cleanup(failedResult(error instanceof Error ? error.message : String(error), ctx)));
188
- }
263
+ // Start the session. Prompt rejection is a distinct host-owned outcome;
264
+ // caught provider or runner errors are intentionally never retained.
265
+ const rejectPrompt = () => {
266
+ if (state.settled || state.aborting) return;
267
+ ctx.recordHostMarker({ type: "prompt_rejected" });
268
+ resolve(cleanup(failedResult("prompt-rejected", ctx)));
269
+ };
270
+ const failUnexpectedly = () => {
271
+ promptSettled = true;
272
+ if (state.settled || state.aborting) return;
273
+ resolve(cleanup(failedResult("unexpected-runner-failure", ctx)));
274
+ };
275
+ startPrompt({
276
+ session,
277
+ prompt,
278
+ onPreflightRejected: rejectPrompt,
279
+ onUnexpectedFailure: failUnexpectedly,
280
+ onFulfilled: () => {
281
+ promptSettled = true;
282
+ flushDeferredSettlement();
283
+ },
189
284
  });
190
285
  });
191
286
  }
package/src/types.ts CHANGED
@@ -1,5 +1,23 @@
1
1
  import type { Model } from "@earendil-works/pi-ai";
2
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+ import type { ChildLifecycleTrace } from "./tool/child-lifecycle-trace.ts";
3
+
4
+ /** Closed, host-owned classification for a managed child failure. */
5
+ export type ChildFailureCode =
6
+ | "session-creation-failed"
7
+ | "prompt-rejected"
8
+ | "missing-structured-output"
9
+ | "unexpected-runner-failure";
10
+
11
+ /** Managed child role used for host-owned failure copy. */
12
+ export type ChildStage = "brief-synthesis" | "reviewer";
13
+
14
+ /** Failed child result fields with diagnostics required exactly when a child was observed. */
15
+ export type ChildFailedResult =
16
+ | { failureCode: "session-creation-failed"; diagnostics?: never }
17
+ | {
18
+ failureCode: Exclude<ChildFailureCode, "session-creation-failed">;
19
+ diagnostics: ChildFailureDiagnostics;
20
+ };
3
21
 
4
22
  /** Inclusive 1-based line range reported by the reviewer. */
5
23
  export interface ReviewLineRange {
@@ -167,23 +185,17 @@ export type AgentReviewerResult =
167
185
  output: NormalizedReviewOutput;
168
186
  modelId: string;
169
187
  }
170
- | {
171
- kind: "failed";
172
- reason: string;
173
- modelId: string;
174
- debug?: ReviewFailureDebugInfo;
175
- }
188
+ | ({ kind: "failed"; modelId: string } & ChildFailedResult)
176
189
  | {
177
190
  kind: "canceled";
178
191
  modelId: string;
179
- debug?: ReviewFailureDebugInfo;
192
+ diagnostics: ChildFailureDiagnostics;
180
193
  }
181
194
  | {
182
195
  kind: "timeout";
183
196
  timeoutMs: number;
184
- partialOutput?: string;
185
197
  modelId: string;
186
- debug?: ReviewFailureDebugInfo;
198
+ diagnostics: ChildFailureDiagnostics;
187
199
  };
188
200
 
189
201
  /** One normalized result from a focused reviewer child session. */
@@ -219,24 +231,6 @@ export interface ReviewPacket {
219
231
  charBudget: number;
220
232
  }
221
233
 
222
- /** Lightweight diagnostics attached to non-success review runs. */
223
- export interface ReviewFailureDebugInfo {
224
- turns: number;
225
- toolUses: number;
226
- tokens?: {
227
- input: number;
228
- output: number;
229
- total: number;
230
- cacheRead?: number;
231
- cacheWrite?: number;
232
- };
233
- recentEvents?: string[];
234
- lastAssistantText?: string;
235
- lastAssistantStopReason?: string;
236
- lastAssistantErrorMessage?: string;
237
- lastAssistantToolCalls?: string[];
238
- }
239
-
240
234
  /** Fully prepared review run. */
241
235
  export interface ReviewPlan {
242
236
  model: ReviewModelSelection;
@@ -254,29 +248,26 @@ export type RawReviewResult =
254
248
  brief?: SynthesizedReviewBrief;
255
249
  modelId: string;
256
250
  }
257
- | {
251
+ | ({
258
252
  kind: "failed";
259
- reason: string;
260
253
  snapshot: ReviewSnapshot;
261
254
  brief?: SynthesizedReviewBrief;
262
255
  modelId: string;
263
- debug?: ReviewFailureDebugInfo;
264
- }
256
+ } & ChildFailedResult)
265
257
  | {
266
258
  kind: "canceled";
267
259
  snapshot: ReviewSnapshot;
268
260
  brief?: SynthesizedReviewBrief;
269
261
  modelId: string;
270
- debug?: ReviewFailureDebugInfo;
262
+ diagnostics: ChildFailureDiagnostics;
271
263
  }
272
264
  | {
273
265
  kind: "timeout";
274
266
  snapshot: ReviewSnapshot;
275
267
  timeoutMs: number;
276
- partialOutput?: string;
277
268
  brief?: SynthesizedReviewBrief;
278
269
  modelId: string;
279
- debug?: ReviewFailureDebugInfo;
270
+ diagnostics: ChildFailureDiagnostics;
280
271
  };
281
272
 
282
273
  /** Normalized result used by rendering and follow-up logic. */
@@ -290,6 +281,28 @@ export type ReviewResult =
290
281
  }
291
282
  | Extract<RawReviewResult, { kind: "failed" | "canceled" | "timeout" }>;
292
283
 
284
+ /**
285
+ * Safe bounded diagnostics attached only to a non-success managed child run.
286
+ *
287
+ * The trace and Recent Activity lane contain only allowlisted control metadata;
288
+ * no child-generated text, caught error, or raw SDK event is retained.
289
+ */
290
+ export interface ChildFailureDiagnostics {
291
+ lifecycleTrace: ChildLifecycleTrace;
292
+ turns: number;
293
+ toolUses: number;
294
+ tokens?: {
295
+ input: number;
296
+ output: number;
297
+ total: number;
298
+ cacheRead?: number;
299
+ cacheWrite?: number;
300
+ };
301
+ recentActivity?: string[];
302
+ lastAssistantStopReason?: string;
303
+ lastAssistantToolCalls?: string[];
304
+ }
305
+
293
306
  /** Progress state exposed by review/synthesis runners for widget integration. */
294
307
  export interface ReviewProgress {
295
308
  /** Number of agent turns completed. */
@@ -318,15 +331,14 @@ export interface ReviewProgress {
318
331
 
319
332
  export type BriefSynthesisRunResult =
320
333
  | { kind: "success"; brief: SynthesizedReviewBrief }
321
- | { kind: "failed"; reason: string }
322
- | { kind: "canceled" }
323
- | { kind: "timeout"; timeoutMs: number };
334
+ | ({ kind: "failed" } & ChildFailedResult)
335
+ | { kind: "canceled"; diagnostics: ChildFailureDiagnostics }
336
+ | { kind: "timeout"; timeoutMs: number; diagnostics: ChildFailureDiagnostics };
324
337
 
325
338
  export interface BriefSynthesisInvocation {
326
339
  prompt: string;
327
340
  // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
328
341
  model: Model<any>;
329
- modelRegistry?: ModelRegistry;
330
342
  cwd: string;
331
343
  signal?: AbortSignal;
332
344
  timeoutMs?: number;
@@ -336,7 +348,6 @@ export interface BriefSynthesisInvocation {
336
348
  export interface ReviewInvocation {
337
349
  prompt: string;
338
350
  model: ReviewModelSelection;
339
- modelRegistry?: ModelRegistry;
340
351
  cwd: string;
341
352
  signal?: AbortSignal;
342
353
  snapshot: ReviewSnapshot;
@@ -1,4 +1,13 @@
1
- import type { ReviewFailureDebugInfo, ReviewItem, ReviewResult } from "../types.ts";
1
+ import {
2
+ formatChildFailureCopy,
3
+ formatChildFailureDiagnostics,
4
+ } from "../tool/child-failure-diagnostics.ts";
5
+ import type {
6
+ BriefSynthesisRunResult,
7
+ ChildFailureDiagnostics,
8
+ ReviewItem,
9
+ ReviewResult,
10
+ } from "../types.ts";
2
11
 
3
12
  /** Format a `impact` or `effort` level value. */
4
13
  export function formatLevel(value: ReviewItem["impact"] | ReviewItem["effort"]): string {
@@ -10,6 +19,29 @@ export function formatLocation(file: string, startLine: number, endLine: number)
10
19
  return `${file}:${startLine === endLine ? startLine : `${startLine}-${endLine}`}`;
11
20
  }
12
21
 
22
+ /** Format one non-success brief-synthesis result for parent-facing custom-message content. */
23
+ export function formatBriefSynthesisFailureContent(
24
+ result: Exclude<BriefSynthesisRunResult, { kind: "success" }>,
25
+ ): string {
26
+ const parts = [formatBriefSynthesisFailureCopy(result)];
27
+ appendChildFailureDiagnostics(parts, result.diagnostics);
28
+ return parts.join("\n");
29
+ }
30
+
31
+ /** Generate short static parent-facing copy for a brief-synthesis outcome. */
32
+ export function formatBriefSynthesisFailureCopy(
33
+ result: Exclude<BriefSynthesisRunResult, { kind: "success" }>,
34
+ ): string {
35
+ switch (result.kind) {
36
+ case "failed":
37
+ return formatChildFailureCopy("brief-synthesis", result.failureCode);
38
+ case "canceled":
39
+ return "Brief synthesis was canceled.";
40
+ case "timeout":
41
+ return "Brief synthesis timed out.";
42
+ }
43
+ }
44
+
13
45
  /** Format review results for the LLM-visible custom message content. */
14
46
  export function formatReviewContent(result: ReviewResult): string {
15
47
  switch (result.kind) {
@@ -25,26 +57,31 @@ export function formatReviewContent(result: ReviewResult): string {
25
57
  }
26
58
 
27
59
  function formatFailureContent(result: Extract<ReviewResult, { kind: "failed" }>): string {
28
- const parts = [`Review failed: ${result.reason}`];
29
- appendDebugContent(parts, result.debug);
60
+ const parts = [formatChildFailureCopy("reviewer", result.failureCode)];
61
+ appendChildFailureDiagnostics(parts, result.diagnostics);
30
62
  return parts.join("\n");
31
63
  }
32
64
 
33
65
  function formatCanceledContent(result: Extract<ReviewResult, { kind: "canceled" }>): string {
34
- const parts = ["Review canceled"];
35
- appendDebugContent(parts, result.debug);
66
+ const parts = ["Reviewer was canceled."];
67
+ appendChildFailureDiagnostics(parts, result.diagnostics);
36
68
  return parts.join("\n");
37
69
  }
38
70
 
39
71
  function formatTimeoutContent(result: Extract<ReviewResult, { kind: "timeout" }>): string {
40
- const parts = [`Review timed out (exceeded ${(result.timeoutMs / 1000).toFixed(0)}s)`];
41
- if (result.partialOutput) {
42
- parts.push("", "Partial output:", result.partialOutput);
43
- }
44
- appendDebugContent(parts, result.debug);
72
+ const parts = ["Reviewer timed out."];
73
+ appendChildFailureDiagnostics(parts, result.diagnostics);
45
74
  return parts.join("\n");
46
75
  }
47
76
 
77
+ function appendChildFailureDiagnostics(
78
+ parts: string[],
79
+ diagnostics: ChildFailureDiagnostics | undefined,
80
+ ): void {
81
+ if (!diagnostics) return;
82
+ parts.push("", "Diagnostics:", ...formatChildFailureDiagnostics(diagnostics));
83
+ }
84
+
48
85
  function formatSuccessContent(result: Extract<ReviewResult, { kind: "success" }>): string {
49
86
  const { output } = result;
50
87
  const confidencePercent = Math.round(output.overall_confidence_score * 100);
@@ -81,34 +118,6 @@ function formatSuccessContent(result: Extract<ReviewResult, { kind: "success" }>
81
118
  return lines.join("\n");
82
119
  }
83
120
 
84
- function appendDebugContent(parts: string[], debug: ReviewFailureDebugInfo | undefined): void {
85
- if (!debug) return;
86
-
87
- const lines = [
88
- `- Turns: ${debug.turns}`,
89
- `- Tool uses: ${debug.toolUses}`,
90
- debug.tokens
91
- ? `- Tokens: ${debug.tokens.input} in / ${debug.tokens.output} out / ${debug.tokens.total} total`
92
- : undefined,
93
- debug.recentEvents && debug.recentEvents.length > 0
94
- ? `- Recent events: ${debug.recentEvents.join(" → ")}`
95
- : undefined,
96
- debug.lastAssistantStopReason
97
- ? `- Last assistant stop: ${debug.lastAssistantStopReason}`
98
- : undefined,
99
- debug.lastAssistantToolCalls && debug.lastAssistantToolCalls.length > 0
100
- ? `- Last assistant tools: ${debug.lastAssistantToolCalls.join(", ")}`
101
- : undefined,
102
- debug.lastAssistantErrorMessage
103
- ? `- Last assistant error: ${debug.lastAssistantErrorMessage}`
104
- : undefined,
105
- ].filter((line): line is string => !!line);
106
-
107
- if (lines.length === 0) return;
108
-
109
- parts.push("", "Debug:", ...lines);
110
- }
111
-
112
121
  function formatReviewItems(items: ReviewItem[]): string[] {
113
122
  return items.flatMap((item, index) => {
114
123
  const location = item.code_location
@@ -1,12 +1,38 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
3
- import type { ReviewFailureDebugInfo, ReviewItem, ReviewResult } from "../types.ts";
4
- import { formatLevel, formatLocation } from "./format-content.ts";
3
+ import {
4
+ formatChildFailureCopy,
5
+ formatChildFailureDiagnostics,
6
+ } from "../tool/child-failure-diagnostics.ts";
7
+ import type {
8
+ BriefSynthesisRunResult,
9
+ ChildFailureDiagnostics,
10
+ ReviewItem,
11
+ ReviewResult,
12
+ ReviewSnapshot,
13
+ } from "../types.ts";
14
+ import { formatBriefSynthesisFailureCopy, formatLevel, formatLocation } from "./format-content.ts";
5
15
 
6
16
  /** Register the custom TUI renderer for `supi-review` messages. */
17
+ interface BriefSynthesisFailureMessage {
18
+ result: Exclude<BriefSynthesisRunResult, { kind: "success" }>;
19
+ snapshot: ReviewSnapshot;
20
+ modelId: string;
21
+ }
22
+
23
+ interface ReviewMessageDetails {
24
+ result?: ReviewResult;
25
+ briefSynthesisFailure?: BriefSynthesisFailureMessage;
26
+ }
27
+
7
28
  export function registerReviewRenderer(pi: ExtensionAPI): void {
8
29
  pi.registerMessageRenderer("supi-review", (message, { expanded }, theme) => {
9
- const result = (message.details as { result?: ReviewResult } | undefined)?.result;
30
+ const details = message.details as ReviewMessageDetails | undefined;
31
+ if (details?.briefSynthesisFailure) {
32
+ return renderBriefSynthesisFailure(details.briefSynthesisFailure, theme);
33
+ }
34
+
35
+ const result = details?.result;
10
36
  if (!result) {
11
37
  return new Text(theme.fg("dim", "No review data"), 1, 0);
12
38
  }
@@ -26,6 +52,31 @@ export function registerReviewRenderer(pi: ExtensionAPI): void {
26
52
  });
27
53
  }
28
54
 
55
+ function renderBriefSynthesisFailure(
56
+ failure: BriefSynthesisFailureMessage,
57
+ theme: Parameters<Parameters<ExtensionAPI["registerMessageRenderer"]>[1]>[2],
58
+ ): Container {
59
+ const container = new Container();
60
+ const title =
61
+ failure.result.kind === "failed"
62
+ ? "◆ Brief Synthesis Failed"
63
+ : failure.result.kind === "timeout"
64
+ ? "◆ Brief Synthesis Timed Out"
65
+ : "◆ Brief Synthesis Canceled";
66
+ const color = failure.result.kind === "failed" ? "error" : "warning";
67
+
68
+ container.addChild(new Text(theme.fg(color, title), 1, 0));
69
+ container.addChild(new Spacer(1));
70
+ container.addChild(new Text(theme.fg("muted", `Model: ${failure.modelId}`), 1, 0));
71
+ container.addChild(new Text(theme.fg("muted", `Snapshot: ${failure.snapshot.title}`), 1, 0));
72
+ container.addChild(new Spacer(1));
73
+ container.addChild(
74
+ new Text(theme.fg(color, formatBriefSynthesisFailureCopy(failure.result)), 1, 0),
75
+ );
76
+ renderChildFailureDiagnostics(container, failure.result.diagnostics, theme);
77
+ return container;
78
+ }
79
+
29
80
  function renderSuccess(
30
81
  result: Extract<ReviewResult, { kind: "success" }>,
31
82
  theme: Parameters<Parameters<ExtensionAPI["registerMessageRenderer"]>[1]>[2],
@@ -160,8 +211,10 @@ function renderFailed(
160
211
  container.addChild(new Text(theme.fg("muted", `Model: ${result.modelId}`), 1, 0));
161
212
  container.addChild(new Text(theme.fg("muted", `Snapshot: ${result.snapshot.title}`), 1, 0));
162
213
  container.addChild(new Spacer(1));
163
- container.addChild(new Text(theme.fg("error", result.reason), 1, 0));
164
- renderFailureDebug(container, result.debug, theme);
214
+ container.addChild(
215
+ new Text(theme.fg("error", formatChildFailureCopy("reviewer", result.failureCode)), 1, 0),
216
+ );
217
+ renderChildFailureDiagnostics(container, result.diagnostics, theme);
165
218
  return container;
166
219
  }
167
220
 
@@ -175,19 +228,8 @@ function renderTimeout(
175
228
  container.addChild(new Text(theme.fg("muted", `Model: ${result.modelId}`), 1, 0));
176
229
  container.addChild(new Text(theme.fg("muted", `Snapshot: ${result.snapshot.title}`), 1, 0));
177
230
  container.addChild(new Spacer(1));
178
- container.addChild(
179
- new Text(
180
- theme.fg("warning", `Reviewer exceeded the ${(result.timeoutMs / 1000).toFixed(0)}s timeout`),
181
- 1,
182
- 0,
183
- ),
184
- );
185
- if (result.partialOutput) {
186
- container.addChild(new Spacer(1));
187
- container.addChild(new Text(theme.fg("dim", "Partial output:"), 1, 0));
188
- container.addChild(new Text(theme.fg("dim", result.partialOutput.slice(0, 500)), 1, 0));
189
- }
190
- renderFailureDebug(container, result.debug, theme);
231
+ container.addChild(new Text(theme.fg("warning", "Reviewer timed out."), 1, 0));
232
+ renderChildFailureDiagnostics(container, result.diagnostics, theme);
191
233
  return container;
192
234
  }
193
235
 
@@ -197,41 +239,21 @@ function renderCanceled(
197
239
  ): Container {
198
240
  const container = new Container();
199
241
  container.addChild(new Text(theme.fg("warning", "◆ Review Canceled"), 1, 0));
200
- renderFailureDebug(container, result.debug, theme);
242
+ container.addChild(new Text(theme.fg("warning", "Reviewer was canceled."), 1, 0));
243
+ renderChildFailureDiagnostics(container, result.diagnostics, theme);
201
244
  return container;
202
245
  }
203
246
 
204
- function renderFailureDebug(
247
+ function renderChildFailureDiagnostics(
205
248
  container: Container,
206
- debug: ReviewFailureDebugInfo | undefined,
249
+ diagnostics: ChildFailureDiagnostics | undefined,
207
250
  theme: Parameters<Parameters<ExtensionAPI["registerMessageRenderer"]>[1]>[2],
208
251
  ): void {
209
- if (!debug) return;
210
-
211
- const lines = [
212
- `Turns: ${debug.turns} · Tool uses: ${debug.toolUses}`,
213
- debug.tokens
214
- ? `Tokens: ${debug.tokens.input} in / ${debug.tokens.output} out / ${debug.tokens.total} total`
215
- : undefined,
216
- debug.recentEvents && debug.recentEvents.length > 0
217
- ? `Recent events: ${debug.recentEvents.join(" → ")}`
218
- : undefined,
219
- debug.lastAssistantStopReason
220
- ? `Last assistant stop: ${debug.lastAssistantStopReason}`
221
- : undefined,
222
- debug.lastAssistantToolCalls && debug.lastAssistantToolCalls.length > 0
223
- ? `Last assistant tools: ${debug.lastAssistantToolCalls.join(", ")}`
224
- : undefined,
225
- debug.lastAssistantErrorMessage
226
- ? `Last assistant error: ${debug.lastAssistantErrorMessage}`
227
- : undefined,
228
- ].filter((line): line is string => !!line);
229
-
230
- if (lines.length === 0) return;
252
+ if (!diagnostics) return;
231
253
 
232
254
  container.addChild(new Spacer(1));
233
- container.addChild(new Text(theme.fg("dim", "Debug:"), 1, 0));
234
- for (const line of lines) {
255
+ container.addChild(new Text(theme.fg("dim", "Diagnostics:"), 1, 0));
256
+ for (const line of formatChildFailureDiagnostics(diagnostics)) {
235
257
  container.addChild(new Text(theme.fg("dim", line), 1, 0));
236
258
  }
237
259
  }
@@ -1,6 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
3
  import type { PrepareAgentReviewInput, RunAgentReviewInput } from "../tool/agent-review-schemas.ts";
4
+ import {
5
+ formatChildFailureCopy,
6
+ formatChildFailureDiagnostics,
7
+ } from "../tool/child-failure-diagnostics.ts";
4
8
  import type {
5
9
  AgentReviewBatchDetails,
6
10
  PreparedAgentReviewDetails,
@@ -150,6 +154,7 @@ export function renderRunReviewResult(
150
154
  }
151
155
 
152
156
  if (expanded) {
157
+ renderReviewerDiagnostics(container, details, theme);
153
158
  container.addChild(new Spacer(1));
154
159
  for (const line of formatCritique(details.evaluation.critique)) {
155
160
  container.addChild(new Text(theme.fg("dim", line), 0, 0));
@@ -165,6 +170,28 @@ export function renderRunReviewResult(
165
170
  return container;
166
171
  }
167
172
 
173
+ function renderReviewerDiagnostics(
174
+ container: Container,
175
+ details: AgentReviewBatchDetails,
176
+ theme: ReviewTheme,
177
+ ): void {
178
+ if (
179
+ !details.results.some((entry) => entry.result.kind !== "success" && entry.result.diagnostics)
180
+ ) {
181
+ return;
182
+ }
183
+
184
+ container.addChild(new Spacer(1));
185
+ container.addChild(new Text(theme.fg("accent", "Reviewer diagnostics"), 0, 0));
186
+ for (const { assignment, result } of details.results) {
187
+ if (result.kind === "success" || !result.diagnostics) continue;
188
+ container.addChild(new Text(theme.fg("muted", `${assignment.id}:`), 0, 0));
189
+ for (const line of formatChildFailureDiagnostics(result.diagnostics)) {
190
+ container.addChild(new Text(theme.fg("dim", line), 0, 0));
191
+ }
192
+ }
193
+ }
194
+
168
195
  function renderProgress(details: AgentReviewProgressDetails, theme: ReviewTheme): Text {
169
196
  if (details.phase === "prepare") {
170
197
  return new Text(theme.fg("warning", "Synthesizing review brief…"), 0, 0);
@@ -189,7 +216,11 @@ function summarizeResult(result: AgentReviewBatchDetails["results"][number]["res
189
216
  text: `${result.output.items.length} item(s), ${result.output.overall_correctness}`,
190
217
  };
191
218
  case "failed":
192
- return { icon: "✗", color: "error", text: result.reason };
219
+ return {
220
+ icon: "✗",
221
+ color: "error",
222
+ text: formatChildFailureCopy("reviewer", result.failureCode),
223
+ };
193
224
  case "canceled":
194
225
  return { icon: "○", color: "warning", text: "canceled" };
195
226
  case "timeout":