@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-review",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "SuPi Review extension — session-aware review command and agent tools",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "README.md"
32
32
  ],
33
33
  "dependencies": {
34
- "@mrclrchtr/supi-core": "2.6.0"
34
+ "@mrclrchtr/supi-core": "2.7.0"
35
35
  },
36
36
  "bundledDependencies": [
37
37
  "@mrclrchtr/supi-core"
@@ -1,4 +1,3 @@
1
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
1
  import { listReviewInstructionBlocks } from "../target/packet.ts";
3
2
  import { runBriefSynthesis } from "../tool/brief-runner.ts";
4
3
  import type {
@@ -15,7 +14,6 @@ export const BRIEF_SYNTHESIS_PROMPT_VERSION = "1";
15
14
 
16
15
  export interface SynthesizeReviewBriefOptions {
17
16
  model: ReviewModelSelection;
18
- modelRegistry: ModelRegistry;
19
17
  cwd: string;
20
18
  snapshot: ReviewSnapshot;
21
19
  serializedContext: string;
@@ -28,13 +26,11 @@ export interface SynthesizeReviewBriefOptions {
28
26
  export function synthesizeReviewBrief(
29
27
  options: SynthesizeReviewBriefOptions,
30
28
  ): Promise<BriefSynthesisRunResult> {
31
- const { model, modelRegistry, cwd, snapshot, serializedContext, note, signal, onProgress } =
32
- options;
29
+ const { model, cwd, snapshot, serializedContext, note, signal, onProgress } = options;
33
30
 
34
31
  return runBriefSynthesis({
35
32
  prompt: buildBriefSynthesisPrompt(snapshot, serializedContext, note),
36
33
  model: model.model,
37
- modelRegistry,
38
34
  cwd,
39
35
  signal,
40
36
  onProgress,
package/src/review.ts CHANGED
@@ -7,6 +7,7 @@ import { synthesizeReviewBrief } from "./history/synthesize.ts";
7
7
  import { normalizeReviewResult } from "./review-result.ts";
8
8
  import { buildReviewPacket } from "./target/packet.ts";
9
9
  import { registerAgentReviewTools } from "./tool/agent-review-tools.ts";
10
+ import { createUnobservedChildFailureDiagnostics } from "./tool/child-failure-diagnostics.ts";
10
11
  import { runReviewer } from "./tool/review-runner.ts";
11
12
  import type {
12
13
  BriefSynthesisRunResult,
@@ -17,7 +18,11 @@ import type {
17
18
  ReviewTargetSpec,
18
19
  } from "./types.ts";
19
20
  import { collectReviewNote, previewReviewPlan, selectModel, selectTarget } from "./ui/flow.ts";
20
- import { formatReviewContent } from "./ui/format-content.ts";
21
+ import {
22
+ formatBriefSynthesisFailureContent,
23
+ formatBriefSynthesisFailureCopy,
24
+ formatReviewContent,
25
+ } from "./ui/format-content.ts";
21
26
  import { registerReviewRenderer } from "./ui/renderer.ts";
22
27
 
23
28
  type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
@@ -64,7 +69,6 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
64
69
  (signal: AbortSignal, onProgress: (p: WidgetProgress) => void) =>
65
70
  synthesizeReviewBrief({
66
71
  model,
67
- modelRegistry: ctx.modelRegistry,
68
72
  cwd: ctx.cwd,
69
73
  snapshot,
70
74
  serializedContext,
@@ -75,22 +79,21 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
75
79
  );
76
80
 
77
81
  if (!synthesis) {
78
- notifyBriefDone(
79
- pi,
80
- {
81
- kind: "failed",
82
- reason: "Brief synthesis encountered an unexpected error",
83
- } as BriefSynthesisRunResult,
84
- snapshot,
85
- model.canonicalId,
86
- );
87
- ctx.ui.notify("Brief synthesis was canceled or failed", "warning");
82
+ const failure: BriefSynthesisRunResult = {
83
+ kind: "failed",
84
+ failureCode: "unexpected-runner-failure",
85
+ diagnostics: createUnobservedChildFailureDiagnostics(),
86
+ };
87
+ notifyBriefDone(pi, failure, snapshot, model.canonicalId);
88
+ injectBriefSynthesisFailureMessage(pi, failure, snapshot, model.canonicalId);
89
+ ctx.ui.notify(formatBriefSynthesisFailureCopy(failure), "warning");
88
90
  return;
89
91
  }
90
92
 
91
93
  notifyBriefDone(pi, synthesis, snapshot, model.canonicalId);
92
94
 
93
95
  if (synthesis.kind !== "success") {
96
+ injectBriefSynthesisFailureMessage(pi, synthesis, snapshot, model.canonicalId);
94
97
  notifySynthesisFailure(synthesis, ctx);
95
98
  return;
96
99
  }
@@ -114,7 +117,6 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
114
117
  runReviewer({
115
118
  prompt: plan.packet.prompt,
116
119
  model: plan.model,
117
- modelRegistry: ctx.modelRegistry,
118
120
  cwd: ctx.cwd,
119
121
  signal,
120
122
  snapshot: plan.snapshot,
@@ -124,14 +126,17 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
124
126
  );
125
127
 
126
128
  if (!rawResult) {
127
- notifyReviewDone(pi, {
129
+ const failure: ReviewResult = {
128
130
  kind: "failed",
129
- reason: "Review encountered an unexpected error",
131
+ failureCode: "unexpected-runner-failure",
132
+ diagnostics: createUnobservedChildFailureDiagnostics(),
130
133
  snapshot,
131
134
  brief,
132
135
  modelId: model.canonicalId,
133
- } as ReviewResult);
134
- ctx.ui.notify("Review was canceled or failed", "warning");
136
+ };
137
+ notifyReviewDone(pi, failure);
138
+ injectReviewMessage(pi, failure);
139
+ ctx.ui.notify("Reviewer ended unexpectedly.", "warning");
135
140
  return;
136
141
  }
137
142
 
@@ -174,20 +179,27 @@ function notifySynthesisFailure(
174
179
  result: Exclude<BriefSynthesisRunResult, { kind: "success" }>,
175
180
  ctx: CommandContext,
176
181
  ): void {
177
- switch (result.kind) {
178
- case "failed":
179
- ctx.ui.notify(result.reason, "error");
180
- break;
181
- case "timeout":
182
- ctx.ui.notify(
183
- `Brief synthesis timed out after ${(result.timeoutMs / 1000).toFixed(0)}s`,
184
- "warning",
185
- );
186
- break;
187
- case "canceled":
188
- ctx.ui.notify("Review canceled", "warning");
189
- break;
190
- }
182
+ ctx.ui.notify(
183
+ formatBriefSynthesisFailureCopy(result),
184
+ result.kind === "failed" ? "error" : "warning",
185
+ );
186
+ }
187
+
188
+ /** Persist a brief-synthesis failure so bounded diagnostics remain inspectable in parent history. */
189
+ function injectBriefSynthesisFailureMessage(
190
+ pi: ExtensionAPI,
191
+ result: Exclude<BriefSynthesisRunResult, { kind: "success" }>,
192
+ snapshot: ReviewSnapshot,
193
+ modelId: string,
194
+ ): void {
195
+ pi.sendMessage({
196
+ customType: "supi-review",
197
+ content: formatBriefSynthesisFailureContent(result),
198
+ display: true,
199
+ details: {
200
+ briefSynthesisFailure: { result, snapshot, modelId },
201
+ },
202
+ });
191
203
  }
192
204
 
193
205
  /** Ring the terminal bell so the user knows to check back. */
@@ -20,6 +20,7 @@ import type {
20
20
  ReviewProgress,
21
21
  ReviewTargetSpec,
22
22
  } from "../types.ts";
23
+ import { formatBriefSynthesisFailureCopy } from "../ui/format-content.ts";
23
24
  import { formatAgentReviewBatch, formatPreparedAgentReview } from "../ui/review-tool-format.ts";
24
25
  import {
25
26
  type AgentReviewProgressDetails,
@@ -35,6 +36,7 @@ import {
35
36
  runAgentReviewSchema,
36
37
  } from "./agent-review-schemas.ts";
37
38
  import { prepareAgentReviewPlan, runAgentReviewBatch } from "./agent-review-workflow.ts";
39
+ import { formatChildLifecycleTrace } from "./child-lifecycle-trace.ts";
38
40
  import {
39
41
  PREPARE_REVIEW_TOOL_NAME,
40
42
  prepareReviewPromptGuidelines,
@@ -81,7 +83,6 @@ export function registerAgentReviewTools(
81
83
  note,
82
84
  serializedContext,
83
85
  model,
84
- modelRegistry: ctx.modelRegistry,
85
86
  signal,
86
87
  planStore,
87
88
  onProgress: (progress) => {
@@ -136,7 +137,6 @@ export function registerAgentReviewTools(
136
137
  critique: input.critique,
137
138
  revisedBrief: input.revisedBrief,
138
139
  reviewers: input.reviewers,
139
- modelRegistry: ctx.modelRegistry,
140
140
  signal,
141
141
  planStore,
142
142
  onBriefEvaluation: (evaluation) => {
@@ -230,14 +230,12 @@ function formatPreparationFailure(
230
230
  outcome: Exclude<Awaited<ReturnType<typeof prepareAgentReviewPlan>>, { kind: "prepared" }>,
231
231
  ): string {
232
232
  if (outcome.kind === "no-snapshot") return outcome.reason;
233
- switch (outcome.result.kind) {
234
- case "failed":
235
- return `Brief synthesis failed: ${outcome.result.reason}`;
236
- case "canceled":
237
- return "Brief synthesis was canceled.";
238
- case "timeout":
239
- return `Brief synthesis timed out after ${(outcome.result.timeoutMs / 1_000).toFixed(0)}s.`;
240
- }
233
+
234
+ const { result } = outcome;
235
+ const trace = result.diagnostics?.lifecycleTrace;
236
+ return trace
237
+ ? `${formatBriefSynthesisFailureCopy(result)}\n\n${formatChildLifecycleTrace(trace)}`
238
+ : formatBriefSynthesisFailureCopy(result);
241
239
  }
242
240
 
243
241
  function activateRunTool(pi: ExtensionAPI): void {
@@ -1,4 +1,3 @@
1
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
1
  import {
3
2
  checkReviewSnapshotFreshness,
4
3
  fingerprintReviewSnapshot,
@@ -23,8 +22,8 @@ import type {
23
22
  ReviewTargetSpec,
24
23
  SynthesizedReviewBrief,
25
24
  } from "../types.ts";
25
+ import { createUnobservedChildFailureDiagnostics } from "./child-failure-diagnostics.ts";
26
26
  import { runReviewer } from "./review-runner.ts";
27
-
28
27
  /** Inputs required to synthesize and retain one agent-driven review plan. */
29
28
  export interface PrepareAgentReviewWorkflowInput {
30
29
  cwd: string;
@@ -32,18 +31,15 @@ export interface PrepareAgentReviewWorkflowInput {
32
31
  note?: string;
33
32
  serializedContext: string;
34
33
  model: ReviewModelSelection;
35
- modelRegistry: ModelRegistry;
36
34
  signal?: AbortSignal;
37
35
  onProgress?: (progress: ReviewProgress) => void;
38
36
  planStore: ReviewPlanStore;
39
37
  }
40
-
41
38
  /** Typed preparation outcome before any reviewer child session starts. */
42
39
  export type PrepareAgentReviewWorkflowOutcome =
43
40
  | { kind: "prepared"; plan: StoredAgentReviewPlan }
44
41
  | { kind: "no-snapshot"; reason: string }
45
42
  | { kind: "synthesis-failed"; result: Exclude<BriefSynthesisRunResult, { kind: "success" }> };
46
-
47
43
  /** Inputs required to critique and execute one prepared review plan. */
48
44
  export interface RunAgentReviewWorkflowInput {
49
45
  cwd: string;
@@ -51,20 +47,17 @@ export interface RunAgentReviewWorkflowInput {
51
47
  critique: BriefCritique;
52
48
  revisedBrief?: Omit<SynthesizedReviewBrief, "note">;
53
49
  reviewers: ReviewerAssignment[];
54
- modelRegistry: ModelRegistry;
55
50
  signal?: AbortSignal;
56
51
  onBriefEvaluation?: (evaluation: BriefEvaluation) => void;
57
52
  onReviewerProgress?: (reviewerId: string, progress: ReviewProgress) => void;
58
53
  onReviewerDone?: (reviewerId: string) => void;
59
54
  planStore: ReviewPlanStore;
60
55
  }
61
-
62
56
  /** Typed batch outcome after validation and snapshot freshness checks. */
63
57
  export type RunAgentReviewWorkflowOutcome =
64
58
  | { kind: "completed"; details: AgentReviewBatchDetails }
65
59
  | { kind: "invalid"; reason: string }
66
60
  | { kind: "stale"; reason: string };
67
-
68
61
  /** Prepare one session-scoped review plan without starting reviewer sessions. */
69
62
  export async function prepareAgentReviewPlan(
70
63
  input: PrepareAgentReviewWorkflowInput,
@@ -77,16 +70,27 @@ export async function prepareAgentReviewPlan(
77
70
  };
78
71
  }
79
72
 
80
- const synthesis = await synthesizeReviewBrief({
81
- model: input.model,
82
- modelRegistry: input.modelRegistry,
83
- cwd: input.cwd,
84
- snapshot,
85
- serializedContext: input.serializedContext,
86
- note: input.note,
87
- signal: input.signal,
88
- onProgress: input.onProgress,
89
- });
73
+ let synthesis: BriefSynthesisRunResult;
74
+ try {
75
+ synthesis = await synthesizeReviewBrief({
76
+ model: input.model,
77
+ cwd: input.cwd,
78
+ snapshot,
79
+ serializedContext: input.serializedContext,
80
+ note: input.note,
81
+ signal: input.signal,
82
+ onProgress: input.onProgress,
83
+ });
84
+ } catch {
85
+ return {
86
+ kind: "synthesis-failed",
87
+ result: {
88
+ kind: "failed",
89
+ failureCode: "unexpected-runner-failure",
90
+ diagnostics: createUnobservedChildFailureDiagnostics(),
91
+ },
92
+ };
93
+ }
90
94
  if (synthesis.kind !== "success") {
91
95
  return { kind: "synthesis-failed", result: synthesis };
92
96
  }
@@ -192,7 +196,6 @@ async function runAssignment(
192
196
  const rawResult = await runReviewer({
193
197
  prompt: packet.prompt,
194
198
  model: plan.model,
195
- modelRegistry: input.modelRegistry,
196
199
  cwd: input.cwd,
197
200
  signal: input.signal,
198
201
  snapshot: plan.snapshot,
@@ -200,13 +203,14 @@ async function runAssignment(
200
203
  onProgress: (progress) => input.onReviewerProgress?.(assignment.id, progress),
201
204
  });
202
205
  return { assignment, result: compactReviewResult(normalizeReviewResult(rawResult)) };
203
- } catch (error) {
206
+ } catch {
204
207
  return {
205
208
  assignment,
206
209
  result: {
207
210
  kind: "failed",
208
- reason: `Reviewer session failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
211
+ failureCode: "unexpected-runner-failure",
209
212
  modelId: plan.model.canonicalId,
213
+ diagnostics: createUnobservedChildFailureDiagnostics(),
210
214
  },
211
215
  };
212
216
  }
@@ -217,21 +221,30 @@ function compactReviewResult(result: ReviewResult): AgentReviewerResult {
217
221
  case "success":
218
222
  return { kind: "success", output: result.output, modelId: result.modelId };
219
223
  case "failed":
224
+ return result.failureCode === "session-creation-failed"
225
+ ? {
226
+ kind: "failed",
227
+ failureCode: result.failureCode,
228
+ modelId: result.modelId,
229
+ }
230
+ : {
231
+ kind: "failed",
232
+ failureCode: result.failureCode,
233
+ modelId: result.modelId,
234
+ diagnostics: result.diagnostics,
235
+ };
236
+ case "canceled":
220
237
  return {
221
- kind: "failed",
222
- reason: result.reason,
238
+ kind: "canceled",
223
239
  modelId: result.modelId,
224
- debug: result.debug,
240
+ diagnostics: result.diagnostics,
225
241
  };
226
- case "canceled":
227
- return { kind: "canceled", modelId: result.modelId, debug: result.debug };
228
242
  case "timeout":
229
243
  return {
230
244
  kind: "timeout",
231
245
  timeoutMs: result.timeoutMs,
232
- partialOutput: result.partialOutput,
233
246
  modelId: result.modelId,
234
- debug: result.debug,
247
+ diagnostics: result.diagnostics,
235
248
  };
236
249
  }
237
250
  }
@@ -11,7 +11,8 @@ import type {
11
11
  BriefSynthesisRunResult,
12
12
  SynthesizedReviewBrief,
13
13
  } from "../types.ts";
14
- import { buildProgressTokens, extractLastAssistantText } from "./runner-helpers.ts";
14
+ import { createEarlyCancellationDiagnostics } from "./child-failure-diagnostics.ts";
15
+ import { buildProgressTokens } from "./runner-helpers.ts";
15
16
  import { reviewBriefSchema } from "./schemas.ts";
16
17
  import { type LifecycleCtx, runWithLifecycle } from "./session-lifecycle.ts";
17
18
 
@@ -67,7 +68,6 @@ async function createBriefSession(
67
68
  const { session } = await createAgentSession({
68
69
  cwd: invocation.cwd,
69
70
  model: invocation.model,
70
- modelRegistry: invocation.modelRegistry,
71
71
  thinkingLevel: clampThinkingLevel(invocation.model, "max"),
72
72
  tools: ["submit_review_brief"],
73
73
  customTools: [submitBriefTool],
@@ -89,24 +89,19 @@ function emitBriefProgress(
89
89
 
90
90
  /** Finalize after retries and compaction recovery, never at the earlier `agent_end` boundary. */
91
91
  function handleAgentSettled(options: {
92
- session: AgentSession;
93
92
  brief: SynthesizedReviewBrief | undefined;
94
93
  state: { settled: boolean; aborting: boolean };
95
94
  cleanup: (result: BriefSynthesisRunResult) => BriefSynthesisRunResult;
95
+ getFailureDiagnostics: LifecycleCtx<BriefSynthesisRunResult>["getFailureDiagnostics"];
96
96
  }): BriefSynthesisRunResult | undefined {
97
- const { session, brief, state, cleanup } = options;
98
- if (state.settled || state.aborting) {
99
- return undefined;
100
- }
101
- if (brief) {
102
- return cleanup({ kind: "success", brief });
103
- }
104
- const lastText = extractLastAssistantText(session.messages);
97
+ const { brief, state, cleanup, getFailureDiagnostics } = options;
98
+ if (state.settled || state.aborting) return undefined;
99
+ if (brief) return cleanup({ kind: "success", brief });
100
+
105
101
  return cleanup({
106
102
  kind: "failed",
107
- reason: lastText
108
- ? `Brief synthesizer did not call submit_review_brief. Assistant said: ${lastText}`
109
- : "Brief synthesizer did not produce any output.",
103
+ failureCode: "missing-structured-output",
104
+ diagnostics: getFailureDiagnostics(),
110
105
  });
111
106
  }
112
107
 
@@ -115,7 +110,7 @@ export async function runBriefSynthesis(
115
110
  invocation: BriefSynthesisInvocation,
116
111
  ): Promise<BriefSynthesisRunResult> {
117
112
  if (invocation.signal?.aborted) {
118
- return { kind: "canceled" };
113
+ return { kind: "canceled", diagnostics: createEarlyCancellationDiagnostics() };
119
114
  }
120
115
 
121
116
  const resultHolder: { value: SynthesizedReviewBrief | undefined } = { value: undefined };
@@ -124,11 +119,8 @@ export async function runBriefSynthesis(
124
119
  let session: AgentSession;
125
120
  try {
126
121
  session = await createBriefSession(invocation, submitBriefTool);
127
- } catch (error) {
128
- return {
129
- kind: "failed",
130
- reason: `Failed to create brief synthesis session: ${error instanceof Error ? error.message : String(error)}`,
131
- };
122
+ } catch {
123
+ return { kind: "failed", failureCode: "session-creation-failed" };
132
124
  }
133
125
 
134
126
  return runWithLifecycle<BriefSynthesisRunResult>({
@@ -156,10 +148,10 @@ export async function runBriefSynthesis(
156
148
  break;
157
149
  case "agent_settled": {
158
150
  const result = handleAgentSettled({
159
- session,
160
151
  brief: resultHolder.value,
161
152
  state: ctx.state,
162
153
  cleanup: ctx.cleanup,
154
+ getFailureDiagnostics: ctx.getFailureDiagnostics,
163
155
  });
164
156
  if (result) {
165
157
  ctx.resolve(result);
@@ -170,11 +162,19 @@ export async function runBriefSynthesis(
170
162
  break;
171
163
  }
172
164
  },
173
- canceledResult: () => ({ kind: "canceled" as const }),
174
- failedResult: (reason) => ({
165
+ canceledResult: (ctx) => ({
166
+ kind: "canceled" as const,
167
+ diagnostics: ctx.getFailureDiagnostics(),
168
+ }),
169
+ failedResult: (failureCode, ctx) => ({
175
170
  kind: "failed" as const,
176
- reason,
171
+ failureCode,
172
+ diagnostics: ctx.getFailureDiagnostics(),
173
+ }),
174
+ timeoutResult: (ms, ctx) => ({
175
+ kind: "timeout" as const,
176
+ timeoutMs: ms,
177
+ diagnostics: ctx.getFailureDiagnostics(),
177
178
  }),
178
- timeoutResult: (ms) => ({ kind: "timeout" as const, timeoutMs: ms }),
179
179
  });
180
180
  }
@@ -0,0 +1,145 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ ChildFailureCode,
4
+ ChildFailureDiagnostics,
5
+ ChildStage,
6
+ ReviewProgress,
7
+ } from "../types.ts";
8
+ import {
9
+ type ChildLifecycleTrace,
10
+ ChildLifecycleTraceCollector,
11
+ formatChildLifecycleTrace,
12
+ getRegisteredToolNames,
13
+ toSafeAssistantStopReason,
14
+ } from "./child-lifecycle-trace.ts";
15
+ import { buildProgressTokens } from "./runner-helpers.ts";
16
+
17
+ /** Inputs used to create one safe non-success child-run diagnostic artifact. */
18
+ export interface BuildChildFailureDiagnosticsInput {
19
+ progress: ReviewProgress;
20
+ lifecycleTrace: ChildLifecycleTrace;
21
+ recentActivity: string[];
22
+ session?: AgentSession;
23
+ }
24
+
25
+ /** Build diagnostics from allowlisted control metadata only. */
26
+ export function buildChildFailureDiagnostics(
27
+ input: BuildChildFailureDiagnosticsInput,
28
+ ): ChildFailureDiagnostics {
29
+ const { session } = input;
30
+ const tokens = session
31
+ ? buildProgressTokens(() => session.getSessionStats())
32
+ : input.progress.tokens;
33
+ const lastAssistant = session ? extractLastAssistantMetadata(session) : undefined;
34
+
35
+ return {
36
+ lifecycleTrace: input.lifecycleTrace,
37
+ turns: input.progress.turns,
38
+ toolUses: input.progress.toolUses,
39
+ tokens,
40
+ recentActivity: input.recentActivity.length > 0 ? [...input.recentActivity] : undefined,
41
+ lastAssistantStopReason: lastAssistant?.stopReason,
42
+ lastAssistantToolCalls: lastAssistant?.toolCalls,
43
+ };
44
+ }
45
+
46
+ /** Create safe diagnostics when a host failure occurred without observed child lifecycle events. */
47
+ export function createUnobservedChildFailureDiagnostics(): ChildFailureDiagnostics {
48
+ return buildChildFailureDiagnostics({
49
+ progress: { turns: 0, toolUses: 0 },
50
+ lifecycleTrace: { entries: [], droppedCount: 0 },
51
+ recentActivity: [],
52
+ });
53
+ }
54
+
55
+ /** Create safe cancellation diagnostics when no child session was started. */
56
+ export function createEarlyCancellationDiagnostics(): ChildFailureDiagnostics {
57
+ const collector = new ChildLifecycleTraceCollector();
58
+ collector.recordHostMarker({ type: "abort_requested", reason: "canceled" });
59
+ return buildChildFailureDiagnostics({
60
+ progress: { turns: 0, toolUses: 0 },
61
+ lifecycleTrace: collector.snapshot(),
62
+ recentActivity: collector.recentActivitySnapshot(),
63
+ });
64
+ }
65
+
66
+ /** Generate static parent-facing copy for a host-owned child failure code. */
67
+ export function formatChildFailureCopy(stage: ChildStage, code: ChildFailureCode): string {
68
+ const label = stage === "brief-synthesis" ? "Brief synthesis" : "Reviewer";
69
+ switch (code) {
70
+ case "session-creation-failed":
71
+ return `${label} session could not be created.`;
72
+ case "prompt-rejected":
73
+ return `${label} prompt was rejected before it ran.`;
74
+ case "missing-structured-output":
75
+ return `${label} ended without the required structured output.`;
76
+ case "unexpected-runner-failure":
77
+ return `${label} ended unexpectedly.`;
78
+ }
79
+ }
80
+
81
+ /** Format the complete retained safe diagnostic artifact for parent-facing text. */
82
+ export function formatChildFailureDiagnostics(diagnostics: ChildFailureDiagnostics): string[] {
83
+ const lines = [
84
+ `- Turns: ${diagnostics.turns}`,
85
+ `- Tool uses: ${diagnostics.toolUses}`,
86
+ diagnostics.tokens
87
+ ? `- Tokens: ${diagnostics.tokens.input} in / ${diagnostics.tokens.output} out / ${diagnostics.tokens.total} total`
88
+ : undefined,
89
+ diagnostics.recentActivity && diagnostics.recentActivity.length > 0
90
+ ? `- Recent activity: ${diagnostics.recentActivity.join(" → ")}`
91
+ : undefined,
92
+ diagnostics.lastAssistantStopReason
93
+ ? `- Last assistant stop: ${diagnostics.lastAssistantStopReason}`
94
+ : undefined,
95
+ diagnostics.lastAssistantToolCalls && diagnostics.lastAssistantToolCalls.length > 0
96
+ ? `- Last assistant tools: ${diagnostics.lastAssistantToolCalls.join(", ")}`
97
+ : undefined,
98
+ `- ${formatChildLifecycleTrace(diagnostics.lifecycleTrace)}`,
99
+ ];
100
+ return lines.filter((line): line is string => !!line);
101
+ }
102
+
103
+ interface LastAssistantMetadata {
104
+ stopReason?: string;
105
+ toolCalls?: string[];
106
+ }
107
+
108
+ function extractLastAssistantMetadata(session: AgentSession): LastAssistantMetadata | undefined {
109
+ try {
110
+ const messages = session.messages as unknown;
111
+ if (!Array.isArray(messages)) return undefined;
112
+
113
+ const registeredToolNames = getRegisteredToolNames(session);
114
+ for (let index = messages.length - 1; index >= 0; index--) {
115
+ const message = messages[index] as Record<string, unknown> | undefined;
116
+ if (message?.role !== "assistant") continue;
117
+
118
+ const stopReason = toSafeAssistantStopReason(message.stopReason);
119
+ const toolCalls = extractAssistantToolCalls(message.content, registeredToolNames);
120
+ return {
121
+ stopReason,
122
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
123
+ };
124
+ }
125
+ return undefined;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ function extractAssistantToolCalls(content: unknown, registeredToolNames: Set<string>): string[] {
132
+ if (!Array.isArray(content)) return [];
133
+
134
+ return content
135
+ .map((part) => {
136
+ if (typeof part !== "object" || !part) return undefined;
137
+ const tool = part as { type?: unknown; name?: unknown };
138
+ return tool.type === "toolCall" &&
139
+ typeof tool.name === "string" &&
140
+ registeredToolNames.has(tool.name)
141
+ ? tool.name
142
+ : undefined;
143
+ })
144
+ .filter((name): name is string => !!name);
145
+ }