@mrclrchtr/supi-review 2.6.1 → 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,4 +1,23 @@
1
1
  import type { Model } from "@earendil-works/pi-ai";
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
+ };
2
21
 
3
22
  /** Inclusive 1-based line range reported by the reviewer. */
4
23
  export interface ReviewLineRange {
@@ -166,23 +185,17 @@ export type AgentReviewerResult =
166
185
  output: NormalizedReviewOutput;
167
186
  modelId: string;
168
187
  }
169
- | {
170
- kind: "failed";
171
- reason: string;
172
- modelId: string;
173
- debug?: ReviewFailureDebugInfo;
174
- }
188
+ | ({ kind: "failed"; modelId: string } & ChildFailedResult)
175
189
  | {
176
190
  kind: "canceled";
177
191
  modelId: string;
178
- debug?: ReviewFailureDebugInfo;
192
+ diagnostics: ChildFailureDiagnostics;
179
193
  }
180
194
  | {
181
195
  kind: "timeout";
182
196
  timeoutMs: number;
183
- partialOutput?: string;
184
197
  modelId: string;
185
- debug?: ReviewFailureDebugInfo;
198
+ diagnostics: ChildFailureDiagnostics;
186
199
  };
187
200
 
188
201
  /** One normalized result from a focused reviewer child session. */
@@ -218,24 +231,6 @@ export interface ReviewPacket {
218
231
  charBudget: number;
219
232
  }
220
233
 
221
- /** Lightweight diagnostics attached to non-success review runs. */
222
- export interface ReviewFailureDebugInfo {
223
- turns: number;
224
- toolUses: number;
225
- tokens?: {
226
- input: number;
227
- output: number;
228
- total: number;
229
- cacheRead?: number;
230
- cacheWrite?: number;
231
- };
232
- recentEvents?: string[];
233
- lastAssistantText?: string;
234
- lastAssistantStopReason?: string;
235
- lastAssistantErrorMessage?: string;
236
- lastAssistantToolCalls?: string[];
237
- }
238
-
239
234
  /** Fully prepared review run. */
240
235
  export interface ReviewPlan {
241
236
  model: ReviewModelSelection;
@@ -253,29 +248,26 @@ export type RawReviewResult =
253
248
  brief?: SynthesizedReviewBrief;
254
249
  modelId: string;
255
250
  }
256
- | {
251
+ | ({
257
252
  kind: "failed";
258
- reason: string;
259
253
  snapshot: ReviewSnapshot;
260
254
  brief?: SynthesizedReviewBrief;
261
255
  modelId: string;
262
- debug?: ReviewFailureDebugInfo;
263
- }
256
+ } & ChildFailedResult)
264
257
  | {
265
258
  kind: "canceled";
266
259
  snapshot: ReviewSnapshot;
267
260
  brief?: SynthesizedReviewBrief;
268
261
  modelId: string;
269
- debug?: ReviewFailureDebugInfo;
262
+ diagnostics: ChildFailureDiagnostics;
270
263
  }
271
264
  | {
272
265
  kind: "timeout";
273
266
  snapshot: ReviewSnapshot;
274
267
  timeoutMs: number;
275
- partialOutput?: string;
276
268
  brief?: SynthesizedReviewBrief;
277
269
  modelId: string;
278
- debug?: ReviewFailureDebugInfo;
270
+ diagnostics: ChildFailureDiagnostics;
279
271
  };
280
272
 
281
273
  /** Normalized result used by rendering and follow-up logic. */
@@ -289,6 +281,28 @@ export type ReviewResult =
289
281
  }
290
282
  | Extract<RawReviewResult, { kind: "failed" | "canceled" | "timeout" }>;
291
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
+
292
306
  /** Progress state exposed by review/synthesis runners for widget integration. */
293
307
  export interface ReviewProgress {
294
308
  /** Number of agent turns completed. */
@@ -317,9 +331,9 @@ export interface ReviewProgress {
317
331
 
318
332
  export type BriefSynthesisRunResult =
319
333
  | { kind: "success"; brief: SynthesizedReviewBrief }
320
- | { kind: "failed"; reason: string }
321
- | { kind: "canceled" }
322
- | { kind: "timeout"; timeoutMs: number };
334
+ | ({ kind: "failed" } & ChildFailedResult)
335
+ | { kind: "canceled"; diagnostics: ChildFailureDiagnostics }
336
+ | { kind: "timeout"; timeoutMs: number; diagnostics: ChildFailureDiagnostics };
323
337
 
324
338
  export interface BriefSynthesisInvocation {
325
339
  prompt: string;
@@ -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":