@dieulc/workflow 0.3.1 → 0.4.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.
package/review-runtime.ts CHANGED
@@ -22,13 +22,17 @@
22
22
  * instructions — the reviewer only supplies judgement.
23
23
  */
24
24
 
25
+ import * as path from "node:path";
26
+
25
27
  import {
26
28
  DEFAULT_REVIEW_MODE_CONFIG,
27
29
  createReviewRoundGate,
28
30
  createReviewSession,
29
31
  createReviewState,
30
32
  planHash,
33
+ readProviderErrorEvent,
31
34
  reviewNeedsVerification,
35
+ truncateErrorText,
32
36
  type CreateReviewSessionOptions,
33
37
  type CreateReviewSessionResult,
34
38
  type ReviewChildSession,
@@ -44,6 +48,7 @@ import {
44
48
  type ReviewVerdict,
45
49
  ReviewTranscript as Transcript,
46
50
  } from "./review.ts";
51
+ import { appendProviderAuthHint } from "./review-providers.ts";
47
52
  import { createReviewTools } from "./review-tools.ts";
48
53
  import { renderReviewContextBlock, type ReviewFinding } from "./utils.ts";
49
54
 
@@ -71,6 +76,15 @@ export interface ReviewRuntimeDeps {
71
76
  | undefined;
72
77
  /** Resolve a model from the parent's registry. */
73
78
  findModel: (provider: string, id: string) => unknown;
79
+ /**
80
+ * Parent-side provider-registration surface for the reviewer runtime, found
81
+ * with `findParentProviderSurface(ctx)` at session-creation time. Providers
82
+ * the parent knows that the isolated child does not compose yet are seeded
83
+ * onto it before `createAgentSession`. Optional: when absent (or when the
84
+ * probe finds no surface) nothing is seeded and the provider-auth preflight
85
+ * reports the generic escape hatch instead.
86
+ */
87
+ parentProviders?: () => unknown;
74
88
  /** Plan path Plan Mode chose (absolute or cwd-relative), if known. */
75
89
  planPath: () => string | undefined;
76
90
  /** Current plan text on disk, if it exists. */
@@ -279,6 +293,29 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
279
293
  let abortController: AbortController | undefined;
280
294
  /** True while `runRound` is in flight (gates prompt routing). */
281
295
  let roundActive = false;
296
+ /**
297
+ * The reviewer's most recent provider failure, captured live from child
298
+ * events (`auto_retry_start`, failed `auto_retry_end`, an `agent_end` that
299
+ * stopped on error). Latest wins. Cleared at the start of every pass so a
300
+ * stale error can never mislabel a later clean settle (see runRound).
301
+ */
302
+ let lastProviderError:
303
+ | { message: string; retries: number; at: number }
304
+ | undefined;
305
+
306
+ /** Record one provider failure. The message arrives pre-decoded, non-empty. */
307
+ const noteProviderError = (message: string, retries: number): void => {
308
+ lastProviderError = {
309
+ message,
310
+ retries: Math.max(0, Math.trunc(retries)),
311
+ at: Date.now(),
312
+ };
313
+ };
314
+
315
+ /** Drop the captured error when a new pass starts. */
316
+ const clearProviderError = (): void => {
317
+ lastProviderError = undefined;
318
+ };
282
319
 
283
320
  const config = (): ReviewModeConfig => {
284
321
  try {
@@ -594,6 +631,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
594
631
  // Reuse the already-resolved module instead of importing twice.
595
632
  loadSdk: async () => sdk,
596
633
  };
634
+ // Probed lazily, at creation time: the parent context is live here.
635
+ const parentSurface = deps.parentProviders?.();
636
+ if (parentSurface !== undefined) {
637
+ createOptions.parentProviders = parentSurface;
638
+ }
597
639
 
598
640
  const created: CreateReviewSessionResult =
599
641
  await createReviewSession(createOptions);
@@ -633,6 +675,29 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
633
675
  }
634
676
  }
635
677
  const type = readEventType(event);
678
+ // Capture provider failures as they happen, so a pass that ends
679
+ // without a submission can be diagnosed with the reviewer's real
680
+ // API error instead of a generic "no plan" message (see runRound).
681
+ try {
682
+ const failure = readProviderErrorEvent(event);
683
+ if (failure.kind === "retry") {
684
+ noteProviderError(failure.message, failure.retries);
685
+ } else if (failure.kind === "exhaust") {
686
+ noteProviderError(
687
+ failure.message ||
688
+ lastProviderError?.message ||
689
+ "provider error (no detail)",
690
+ failure.retries || lastProviderError?.retries || 0,
691
+ );
692
+ } else if (failure.kind === "stop") {
693
+ noteProviderError(
694
+ failure.message,
695
+ lastProviderError?.retries ?? 0,
696
+ );
697
+ }
698
+ } catch {
699
+ /* error capture is diagnostics only: never break the review */
700
+ }
636
701
  if (type === "agent_settled") {
637
702
  noteSettled();
638
703
  } else if (type === "agent_end") {
@@ -738,6 +803,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
738
803
  // ── Pass 1 ──────────────────────────────────────────────────────
739
804
  await settleInFlight(deadline, rc);
740
805
  gate.reset();
806
+ clearProviderError();
741
807
  const pass1Prompt = buildPass1Prompt({
742
808
  planPath: input.planPath,
743
809
  planText: originalPlan,
@@ -755,7 +821,16 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
755
821
 
756
822
  if (p1.stop === "error") {
757
823
  await abortChild();
758
- return fail(`could not prompt the reviewer: ${errorText(p1.error)}`);
824
+ // Safety net for any provider-source gap the preflight could not see:
825
+ // the bare "No API key found for <provider>." gains the actionable
826
+ // hint ONLY when the failing provider is the reviewer's own.
827
+ return fail(
828
+ `could not prompt the reviewer: ${appendProviderAuthHint(
829
+ errorText(p1.error),
830
+ deps.reviewerModel()?.provider,
831
+ path.join(deps.agentDir, "models.json"),
832
+ )}`,
833
+ );
759
834
  }
760
835
  if (p1.stop === "aborted" || rc.signal.aborted) {
761
836
  await abortChild();
@@ -773,10 +848,23 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
773
848
  // The run is over (or out of time) and nothing reached the plan file.
774
849
  // Fail now — waiting longer cannot produce a submission.
775
850
  await abortChild();
851
+ if (p1.stop === "timeout") {
852
+ return fail(
853
+ `the reviewer did not finish within ${Math.round((limitMs ?? 0) / 1000)}s and submitted no plan`,
854
+ originalPlan,
855
+ );
856
+ }
857
+ // A provider failure settles the run without a submission — name the
858
+ // real cause (with the provider's own message) instead of blaming the
859
+ // write path. The slot only holds errors captured during THIS pass.
860
+ if (lastProviderError) {
861
+ return fail(
862
+ `the reviewer's provider failed after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`,
863
+ originalPlan,
864
+ );
865
+ }
776
866
  return fail(
777
- p1.stop === "timeout"
778
- ? `the reviewer did not finish within ${Math.round((limitMs ?? 0) / 1000)}s and submitted no plan`
779
- : "the reviewer finished without submitting a plan — it has no write path unless review_submit_plan succeeds",
867
+ "the reviewer finished without submitting a plan — it has no write path unless review_submit_plan succeeds",
780
868
  originalPlan,
781
869
  );
782
870
  }
@@ -802,6 +890,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
802
890
  setPhase("verifying");
803
891
  await settleInFlight(deadline, rc);
804
892
  gate.reset();
893
+ clearProviderError();
805
894
  const pass2Prompt = buildPass2Prompt({
806
895
  planPath: input.planPath,
807
896
  originalPlan,
@@ -811,14 +900,28 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
811
900
  const p2 = await runPass(2, deadline, rc, () =>
812
901
  child.followUp(pass2Prompt),
813
902
  );
903
+ // Name the provider when the verification pass died on one; pass 2
904
+ // stays best-effort either way — pass 1's plan is never lost to it.
905
+ const p2Provider = lastProviderError
906
+ ? `provider error after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`
907
+ : undefined;
814
908
  if (p2.error !== undefined) {
815
909
  // Verification is an enhancement: never lose pass 1's plan over it.
816
910
  deps.log(
817
- `Review Mode verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
911
+ p2Provider
912
+ ? `Review Mode verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
913
+ : `Review Mode verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
818
914
  "warning",
819
915
  );
820
916
  } else if (p2.submitted) {
821
917
  finalPlan = p2.submitted.planText;
918
+ } else if (p2Provider) {
919
+ // The run settled without a submission AND a provider error was
920
+ // seen this pass: say why, then keep going with pass 1's rewrite.
921
+ deps.log(
922
+ `Review Mode verification pass ended without a submission (${p2Provider}) — keeping pass 1's plan.`,
923
+ "warning",
924
+ );
822
925
  }
823
926
  if (p2.stop === "timeout") await abortChild();
824
927
  }
package/review-ui.ts CHANGED
@@ -253,6 +253,7 @@ const LINE_COLORS: Record<ReviewEntryLineKind, ThemeColor> = {
253
253
  finding: "warning",
254
254
  notice: "warning",
255
255
  status: "muted",
256
+ error: "error",
256
257
  user: "success",
257
258
  };
258
259
 
package/review.ts CHANGED
@@ -36,6 +36,13 @@ import {
36
36
  type ReviewFinding,
37
37
  type ReviewTier,
38
38
  } from "./utils.ts";
39
+ import {
40
+ checkReviewerProviderAuth,
41
+ isParentProviderSurface,
42
+ seedChildRuntimeProviders,
43
+ unconfiguredReviewerProviderMessage,
44
+ type ProviderSeedResult,
45
+ } from "./review-providers.ts";
39
46
 
40
47
  // Re-exported so `review.ts` is the single import surface for Review Mode.
41
48
  export type { ReviewDisposition, ReviewFinding, ReviewTier };
@@ -567,7 +574,8 @@ export type ReviewLineKind =
567
574
  | "tool"
568
575
  | "finding"
569
576
  | "notice"
570
- | "status";
577
+ | "status"
578
+ | "error";
571
579
 
572
580
  /** One line in the reviewer pane. Kept small: the pane renders these verbatim. */
573
581
  export interface ReviewTranscriptLine {
@@ -1099,6 +1107,103 @@ function truncateLabel(s: string, max: number): string {
1099
1107
  return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`;
1100
1108
  }
1101
1109
 
1110
+ /** Longest provider-error excerpt shown inline in a transcript line. */
1111
+ export const MAX_ERROR_TEXT = 160;
1112
+
1113
+ /**
1114
+ * One-line excerpt of a provider error message for `notify` and the reviewer
1115
+ * pane. Provider errors are often multi-line JSON blobs; they must become a
1116
+ * single line to survive a transcript entry and a `ui.notify` banner.
1117
+ * Pure and total: empty/undefined input yields "", never throws.
1118
+ */
1119
+ export function truncateErrorText(input: unknown, max: number = MAX_ERROR_TEXT): string {
1120
+ const raw = typeof input === "string" ? input : "";
1121
+ const flat = raw.replace(/\s+/g, " ").trim();
1122
+ if (!flat) return "";
1123
+ const cap = Math.max(1, Math.trunc(max));
1124
+ return flat.length <= cap ? flat : `${flat.slice(0, cap - 1).trimEnd()}…`;
1125
+ }
1126
+
1127
+ /**
1128
+ * Extract the provider error message when a run stopped on an error: scan an
1129
+ * `agent_end` event's messages from the end for the last assistant message
1130
+ * with `stopReason === "error"`. Structural on purpose — SDK message shapes
1131
+ * are not imported here. Returns undefined for clean runs and junk input.
1132
+ */
1133
+ export function lastAssistantErrorStop(messages: unknown): string | undefined {
1134
+ if (!Array.isArray(messages)) return undefined;
1135
+ for (let i = messages.length - 1; i >= 0; i--) {
1136
+ const rec = messages[i] as Record<string, unknown> | null | undefined;
1137
+ if (!rec || typeof rec !== "object") continue;
1138
+ if (rec.role !== "assistant") continue;
1139
+ // Last assistant message reached: a non-error stop means a clean run.
1140
+ if (rec.stopReason !== "error") return undefined;
1141
+ const err = typeof rec.errorMessage === "string" ? rec.errorMessage : "";
1142
+ return err || "unknown provider error";
1143
+ }
1144
+ return undefined;
1145
+ }
1146
+
1147
+ /**
1148
+ * What a child-session event says about provider health, decoded at the
1149
+ * boundary so callers branch on typed values instead of SDK event shapes:
1150
+ *
1151
+ * - `retry` → the SDK scheduled another attempt (`auto_retry_start`) and
1152
+ * reported the provider's error message.
1153
+ * - `exhaust` → the retry budget ran out (`auto_retry_end`, `success: false`);
1154
+ * the run is about to settle on an error. `message` may be empty when the
1155
+ * event carries no `finalError` — callers then keep their earlier message.
1156
+ * - `stop` → the run itself ended with `stopReason === "error"` on its last
1157
+ * assistant message (auto-retry disabled, or a non-retryable failure).
1158
+ * - `none` → anything else, including a retry that recovered.
1159
+ */
1160
+ export type ReviewProviderFailure =
1161
+ | { kind: "retry"; message: string; retries: number }
1162
+ | { kind: "exhaust"; message: string; retries: number }
1163
+ | { kind: "stop"; message: string }
1164
+ | { kind: "none" };
1165
+
1166
+ /** Structural decode of one provider-failure event. Never throws. */
1167
+ export function readProviderErrorEvent(
1168
+ event: unknown,
1169
+ ): ReviewProviderFailure {
1170
+ const e = eventRecord(event);
1171
+ if (!e) return { kind: "none" };
1172
+ const type = typeof e.type === "string" ? e.type : "";
1173
+ if (type === "auto_retry_start") {
1174
+ const message =
1175
+ typeof e.errorMessage === "string" ? e.errorMessage.trim() : "";
1176
+ if (!message) return { kind: "none" };
1177
+ return {
1178
+ kind: "retry",
1179
+ message,
1180
+ retries: readRetryAttempt(e.attempt),
1181
+ };
1182
+ }
1183
+ if (type === "auto_retry_end") {
1184
+ if (e.success !== false) return { kind: "none" };
1185
+ const message = typeof e.finalError === "string" ? e.finalError.trim() : "";
1186
+ return {
1187
+ kind: "exhaust",
1188
+ message,
1189
+ retries: readRetryAttempt(e.attempt),
1190
+ };
1191
+ }
1192
+ if (type === "agent_end") {
1193
+ const message = lastAssistantErrorStop(e.messages);
1194
+ if (message === undefined) return { kind: "none" };
1195
+ return { kind: "stop", message };
1196
+ }
1197
+ return { kind: "none" };
1198
+ }
1199
+
1200
+ /** Non-negative attempt count from a retry event; 0 when absent or junk. */
1201
+ function readRetryAttempt(value: unknown): number {
1202
+ return typeof value === "number" && Number.isFinite(value) && value > 0
1203
+ ? Math.trunc(value)
1204
+ : 0;
1205
+ }
1206
+
1102
1207
  /**
1103
1208
  * Human-readable header for a tool call: the tool name plus a short, safe
1104
1209
  * excerpt of its key argument (`▸ read src/app.ts`, `▸ review_bash: git log -5`).
@@ -1170,6 +1275,12 @@ export function formatActionLabel(toolName: unknown, args: unknown): string {
1170
1275
  * `toolLabels` is owned by the stateful caller (`ReviewTranscript`): the end
1171
1276
  * event carries no args, so the start event registers `toolCallId → label` and
1172
1277
  * the end event consumes it. Pure otherwise; junk input never throws.
1278
+ *
1279
+ * Provider failures are surfaced with their real (truncated) text: the
1280
+ * `auto_retry_start` notice carries the provider's message, and a failed
1281
+ * `auto_retry_end` or an `agent_end` that stopped on `stopReason === "error"`
1282
+ * appends an `error` line — a dead provider is then visible in the pane during
1283
+ * the review instead of resurfacing later as a mysterious "no plan" failure.
1173
1284
  */
1174
1285
  export function translateReviewEvent(
1175
1286
  event: unknown,
@@ -1207,15 +1318,70 @@ export function translateReviewEvent(
1207
1318
  if (type === "agent_start") {
1208
1319
  return [{ op: "new", kind: "status", text: "reviewer started" }];
1209
1320
  }
1321
+ return providerErrorOps(e, type);
1322
+ }
1323
+
1324
+ /**
1325
+ * Provider-error events → transcript lines, split out of
1326
+ * `translateReviewEvent` to keep the dispatcher flat:
1327
+ *
1328
+ * - `agent_end` → status line, plus an `error` line when the run
1329
+ * stopped on `stopReason === "error"` (auto-retry disabled, or the first
1330
+ * failure was not retryable).
1331
+ * - `auto_retry_start` → notice carrying the provider's message (truncated).
1332
+ * - `auto_retry_end` → `error` line when the retry budget is exhausted
1333
+ * (`success: false`); a recovered retry (`success: true`) stays silent.
1334
+ *
1335
+ * Unknown types and junk fields degrade to empty ops; never throws.
1336
+ */
1337
+ function providerErrorOps(
1338
+ e: Record<string, unknown>,
1339
+ type: string,
1340
+ ): ReviewTranscriptOp[] {
1210
1341
  if (type === "agent_end") {
1211
- return [{ op: "new", kind: "status", text: "reviewer finished this run" }];
1342
+ const ops: ReviewTranscriptOp[] = [
1343
+ { op: "new", kind: "status", text: "reviewer finished this run" },
1344
+ ];
1345
+ const failed = lastAssistantErrorStop(e.messages);
1346
+ if (failed) {
1347
+ ops.push({
1348
+ op: "new",
1349
+ kind: "error",
1350
+ text: `reviewer run ended on a provider error: ${truncateErrorText(failed)}`,
1351
+ });
1352
+ }
1353
+ return ops;
1212
1354
  }
1213
1355
  if (type === "auto_retry_start") {
1356
+ const msg = typeof e.errorMessage === "string" ? e.errorMessage : "";
1214
1357
  return [
1215
- { op: "new", kind: "notice", text: "retrying after a provider error" },
1358
+ {
1359
+ op: "new",
1360
+ kind: "notice",
1361
+ text: msg
1362
+ ? `retrying after a provider error: ${truncateErrorText(msg)}`
1363
+ : "retrying after a provider error",
1364
+ },
1216
1365
  ];
1217
1366
  }
1218
-
1367
+ if (type === "auto_retry_end") {
1368
+ // `success: false` ends the retry loop with the provider still failing —
1369
+ // the run is about to settle on an error stop. `success: true` means a
1370
+ // retry recovered; the earlier notice already covers that case.
1371
+ if (e.success === false) {
1372
+ const detail = truncateErrorText(e.finalError);
1373
+ return [
1374
+ {
1375
+ op: "new",
1376
+ kind: "error",
1377
+ text: detail
1378
+ ? `reviewer retry budget exhausted: ${detail}`
1379
+ : "reviewer retry budget exhausted",
1380
+ },
1381
+ ];
1382
+ }
1383
+ return [];
1384
+ }
1219
1385
  return [];
1220
1386
  }
1221
1387
 
@@ -1443,6 +1609,23 @@ export interface CreateReviewSessionOptions {
1443
1609
  reviewPrompt: string;
1444
1610
  /** Reviewer-only tools, built by the caller. */
1445
1611
  customTools: unknown[];
1612
+ /**
1613
+ * Parent-side provider-registration surface (structural, feature-detected),
1614
+ * found with `findParentProviderSurface(ctx)` — the session's model registry
1615
+ * facade or its underlying model runtime.
1616
+ *
1617
+ * The reviewer session is deliberately created without ambient extensions,
1618
+ * so a provider registered only in the parent session (e.g. by a provider
1619
+ * extension) does not exist in the reviewer's fresh runtime. When this
1620
+ * surface is given, every provider the parent knows that the child does not
1621
+ * yet compose is registered onto the child BEFORE `createAgentSession` —
1622
+ * natively when the parent has a provider object, otherwise by config.
1623
+ * Absent or surface-less ⇒ nothing is seeded and the provider-auth preflight
1624
+ * (below) reports the generic escape hatch instead. Providers the child
1625
+ * already composes are skipped, so this is a no-op for every provider the
1626
+ * runtime builds in by construction.
1627
+ */
1628
+ parentProviders?: unknown;
1446
1629
  /** Injectable for tests; defaults to a real dynamic import. */
1447
1630
  loadSdk?: ReviewSdkLoader;
1448
1631
  }
@@ -1453,6 +1636,8 @@ export interface CreateReviewSessionResult {
1453
1636
  modelLabel: string;
1454
1637
  /** Tool names the child actually has enabled, when the SDK exposes them. */
1455
1638
  activeTools?: string[];
1639
+ /** What provider seeding did before the session was created, when probed. */
1640
+ providerSeed?: ProviderSeedResult;
1456
1641
  error?: string;
1457
1642
  }
1458
1643
 
@@ -1601,6 +1786,36 @@ export async function createReviewSession(
1601
1786
  };
1602
1787
  }
1603
1788
 
1789
+ // Seed providers the child does not compose yet (e.g. providers registered
1790
+ // only in the parent session) BEFORE the session exists. Present-probe:
1791
+ // only the difference is registered, native-first, per-provider
1792
+ // fault-isolated — never fatal, never layer-aware.
1793
+ const providerSeed = seedChildRuntimeProviders(
1794
+ modelRuntime,
1795
+ options.parentProviders,
1796
+ );
1797
+
1798
+ // Provider-agnostic auth preflight, mirroring the child session's own
1799
+ // prompt gate: fail fast here — with the seed report and the generic
1800
+ // escape hatch — instead of mid-review with a bare "No API key found".
1801
+ const auth = await checkReviewerProviderAuth(
1802
+ modelRuntime,
1803
+ options.modelRef.provider,
1804
+ );
1805
+ if (!auth.configured) {
1806
+ return {
1807
+ ok: false,
1808
+ modelLabel,
1809
+ providerSeed,
1810
+ error: unconfiguredReviewerProviderMessage({
1811
+ providerId: options.modelRef.provider,
1812
+ seed: providerSeed,
1813
+ parentSurfaceFound: isParentProviderSurface(options.parentProviders),
1814
+ modelsJsonPath: path.join(options.agentDir, "models.json"),
1815
+ }),
1816
+ };
1817
+ }
1818
+
1604
1819
  // No ambient extensions, no skills/prompts/themes: the reviewer gets exactly
1605
1820
  // the system prompt below and nothing from the user's other packages.
1606
1821
  // Project context files (AGENTS.md etc.) DO load — they are the framework