@mrclrchtr/supi-review 2.6.1 → 2.8.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.
@@ -6,7 +6,7 @@ import {
6
6
  SessionManager,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import type { RawReviewResult, ReviewInvocation, ReviewOutputEvent } from "../types.ts";
9
- import { buildFailureDebug, extractLastAssistantDebug } from "./review-debug.ts";
9
+ import { createEarlyCancellationDiagnostics } from "./child-failure-diagnostics.ts";
10
10
  import {
11
11
  createSubmitReviewTool,
12
12
  handleSessionEvent,
@@ -69,16 +69,12 @@ function buildTimeoutResult(
69
69
  runner: ReviewerRunnerState,
70
70
  ): RawReviewResult {
71
71
  return {
72
- kind: "timeout" as const,
72
+ kind: "timeout",
73
73
  snapshot: runner.invocation.snapshot,
74
74
  timeoutMs: runner.invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
75
75
  brief: runner.invocation.brief,
76
76
  modelId: runner.invocation.model.canonicalId,
77
- debug: buildFailureDebug({
78
- progress: lcCtx.progress,
79
- session: lcCtx.session,
80
- recentEvents: runner.debug.recentEvents,
81
- }),
77
+ diagnostics: lcCtx.getFailureDiagnostics(),
82
78
  };
83
79
  }
84
80
 
@@ -87,34 +83,26 @@ function buildCanceledResult(
87
83
  runner: ReviewerRunnerState,
88
84
  ): RawReviewResult {
89
85
  return {
90
- kind: "canceled" as const,
86
+ kind: "canceled",
91
87
  snapshot: runner.invocation.snapshot,
92
88
  brief: runner.invocation.brief,
93
89
  modelId: runner.invocation.model.canonicalId,
94
- debug: buildFailureDebug({
95
- progress: lcCtx.progress,
96
- session: lcCtx.session,
97
- recentEvents: runner.debug.recentEvents,
98
- }),
90
+ diagnostics: lcCtx.getFailureDiagnostics(),
99
91
  };
100
92
  }
101
93
 
102
94
  function buildFailedResult(
103
- reason: string,
95
+ failureCode: "prompt-rejected" | "unexpected-runner-failure",
104
96
  lcCtx: LifecycleCtx<RawReviewResult>,
105
97
  runner: ReviewerRunnerState,
106
98
  ): RawReviewResult {
107
99
  return {
108
- kind: "failed" as const,
109
- reason,
100
+ kind: "failed",
101
+ failureCode,
110
102
  snapshot: runner.invocation.snapshot,
111
103
  brief: runner.invocation.brief,
112
104
  modelId: runner.invocation.model.canonicalId,
113
- debug: buildFailureDebug({
114
- progress: lcCtx.progress,
115
- session: lcCtx.session,
116
- recentEvents: runner.debug.recentEvents,
117
- }),
105
+ diagnostics: lcCtx.getFailureDiagnostics(),
118
106
  };
119
107
  }
120
108
 
@@ -124,49 +112,43 @@ interface ReviewerRunnerState {
124
112
  submitSteered: boolean;
125
113
  timeoutSteered: boolean;
126
114
  graceTurnsRemaining: number | undefined;
127
- debug: { recentEvents: string[] };
128
115
  }
129
116
 
130
117
  // ---------------------------------------------------------------------------
131
118
  // Steer / abort helpers
132
119
  // ---------------------------------------------------------------------------
133
120
 
134
- /** Abort the session and resolve with a timeout result (from lifecycle context). */
121
+ /** Abort the session and resolve with a timeout result from the lifecycle context. */
135
122
  function doFinalAbortFromLifecycle(
136
123
  lcCtx: LifecycleCtx<RawReviewResult>,
137
124
  runner: ReviewerRunnerState,
138
125
  ): void {
126
+ if (lcCtx.state.settled || lcCtx.state.aborting) return;
127
+
139
128
  lcCtx.state.aborting = true;
129
+ lcCtx.recordHostMarker({ type: "abort_requested", reason: "timeout" });
140
130
  void lcCtx.session
141
131
  .abort()
142
132
  .catch(() => {})
143
133
  .finally(() => {
144
- const partialText = extractLastAssistantDebug(lcCtx.session)?.text;
145
134
  lcCtx.resolve(
146
135
  lcCtx.cleanup({
147
- kind: "timeout" as const,
136
+ kind: "timeout",
148
137
  snapshot: runner.invocation.snapshot,
149
138
  timeoutMs: runner.invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
150
- partialOutput: partialText,
151
139
  brief: runner.invocation.brief,
152
140
  modelId: runner.invocation.model.canonicalId,
153
- debug: buildFailureDebug({
154
- progress: lcCtx.progress,
155
- session: lcCtx.session,
156
- recentEvents: runner.debug.recentEvents,
157
- }),
141
+ diagnostics: lcCtx.getFailureDiagnostics(),
158
142
  }),
159
143
  );
160
144
  });
161
145
  }
162
146
 
163
147
  /**
164
- * Build the custom `onTimeout` handler for the review runner.
148
+ * Build the custom soft-timeout behavior for reviewer sessions.
165
149
  *
166
- * When the soft timeout fires, the handler:
167
- * 1. Steers the reviewer session to wrap up
168
- * 2. Starts a grace timer (hard abort after GRACE_TURNS or HARD_ABORT_GRACE_MS)
169
- * 3. If steer fails, immediately hard-aborts
150
+ * The shared lifecycle runner records `timeout_expired`; this runner adds a
151
+ * timeout steering marker and later records the hard-abort marker if needed.
170
152
  */
171
153
  function buildReviewTimeoutHandler(
172
154
  runner: ReviewerRunnerState,
@@ -174,9 +156,9 @@ function buildReviewTimeoutHandler(
174
156
  ): void {
175
157
  runner.timeoutSteered = true;
176
158
  runner.graceTurnsRemaining = GRACE_TURNS;
159
+ lcCtx.recordHostMarker({ type: "steer_requested", reason: "timeout" });
177
160
 
178
161
  const hardAbortTimer = setTimeout(() => {
179
- if (lcCtx.state.settled) return;
180
162
  doFinalAbortFromLifecycle(lcCtx, runner);
181
163
  }, HARD_ABORT_GRACE_MS);
182
164
  hardAbortTimer.unref?.();
@@ -199,7 +181,6 @@ function buildRunnerCtx(runner: ReviewerRunnerState): RunnerContext {
199
181
  ctx.submitSteered = runner.submitSteered;
200
182
  ctx.timeoutSteered = runner.timeoutSteered;
201
183
  ctx.graceTurnsRemaining = runner.graceTurnsRemaining;
202
- ctx.debug = runner.debug;
203
184
  ctx.toolCounts = {};
204
185
  ctx.inspectedFiles = new Set();
205
186
  return ctx;
@@ -216,6 +197,8 @@ function syncCtxFromLifecycle(
216
197
  ctx.cleanup = lcCtx.cleanup;
217
198
  ctx.state = lcCtx.state;
218
199
  ctx.startTime = lcCtx.startTime;
200
+ ctx.getFailureDiagnostics = lcCtx.getFailureDiagnostics;
201
+ ctx.recordHostMarker = lcCtx.recordHostMarker;
219
202
  ctx.submitSteered = runner.submitSteered;
220
203
  ctx.timeoutSteered = runner.timeoutSteered;
221
204
  ctx.graceTurnsRemaining = runner.graceTurnsRemaining;
@@ -239,6 +222,7 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
239
222
  snapshot: invocation.snapshot,
240
223
  brief: invocation.brief,
241
224
  modelId: invocation.model.canonicalId,
225
+ diagnostics: createEarlyCancellationDiagnostics(),
242
226
  };
243
227
  }
244
228
 
@@ -255,10 +239,10 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
255
239
  snapshotDiffTool,
256
240
  snapshotFileTool,
257
241
  );
258
- } catch (error) {
242
+ } catch {
259
243
  return {
260
244
  kind: "failed",
261
- reason: `Failed to create reviewer session: ${error instanceof Error ? error.message : String(error)}`,
245
+ failureCode: "session-creation-failed",
262
246
  snapshot: invocation.snapshot,
263
247
  brief: invocation.brief,
264
248
  modelId: invocation.model.canonicalId,
@@ -271,9 +255,7 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
271
255
  submitSteered: false,
272
256
  timeoutSteered: false,
273
257
  graceTurnsRemaining: undefined,
274
- debug: { recentEvents: [] },
275
258
  };
276
-
277
259
  const ctx = buildRunnerCtx(runner);
278
260
 
279
261
  return runWithLifecycle<RawReviewResult>({
@@ -288,7 +270,7 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
288
270
  },
289
271
  onTimeout: (lcCtx) => buildReviewTimeoutHandler(runner, lcCtx),
290
272
  canceledResult: (lcCtx) => buildCanceledResult(lcCtx, runner),
291
- failedResult: (reason, lcCtx) => buildFailedResult(reason, lcCtx, runner),
273
+ failedResult: (failureCode, lcCtx) => buildFailedResult(failureCode, lcCtx, runner),
292
274
  timeoutResult: (_, lcCtx) => buildTimeoutResult(lcCtx, runner),
293
275
  });
294
276
  }
@@ -5,20 +5,6 @@
5
5
  * eliminate duplication.
6
6
  */
7
7
 
8
- /** Extract the last assistant text from a session's message history. */
9
- export function extractLastAssistantText(
10
- messages: ArrayLike<unknown> | undefined,
11
- ): string | undefined {
12
- if (!messages) return undefined;
13
- for (let i = messages.length - 1; i >= 0; i--) {
14
- const message = messages[i] as { role?: string; content?: unknown } | undefined;
15
- if (message?.role !== "assistant") continue;
16
- const text = extractAssistantText(message.content);
17
- if (text) return text;
18
- }
19
- return undefined;
20
- }
21
-
22
8
  /** Extract text content from a message content value (string | content-part[]). */
23
9
  export function extractAssistantText(content: unknown): string | undefined {
24
10
  if (typeof content === "string") {
@@ -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