taskplane 0.30.4 → 0.30.6

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.
@@ -23,13 +23,22 @@
23
23
 
24
24
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
25
25
  import { Type } from "@mariozechner/pi-ai";
26
- import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
26
+ import {
27
+ writeFileSync,
28
+ readFileSync,
29
+ readdirSync,
30
+ existsSync,
31
+ mkdirSync,
32
+ renameSync,
33
+ unlinkSync,
34
+ } from "fs";
27
35
  import { join, dirname } from "path";
28
36
  import { spawn as nodeSpawn } from "child_process";
29
37
  import { resolvePiCliPath, resolveTaskplaneAgentTemplate } from "./path-resolver.ts";
30
38
  import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
31
39
  import { randomBytes } from "crypto";
32
40
  import { buildExpansionRequestId, type SegmentExpansionRequest } from "./types.ts";
41
+ import { latestReviewFilesPerGate, parseReviewVerdict } from "./review-analysis.ts";
33
42
 
34
43
  /**
35
44
  * Resolve the outbox directory from environment variables.
@@ -174,6 +183,28 @@ function writeSegmentExpansionRequest(request: SegmentExpansionRequest): string
174
183
  * @param stepNum the step number being reviewed
175
184
  * @returns true iff the step is marked Complete in STATUS.md
176
185
  */
186
+ /**
187
+ * #629: does the LATEST review file for `{type}-step{N}` read REVISE/RETHINK?
188
+ * Used to exempt a remediation re-review from the TP-186 complete-step guard.
189
+ * Fail-closed: unreadable dir/file → false (guard stays in force).
190
+ */
191
+ export function hasOutstandingNonApproveReview(
192
+ reviewsDir: string,
193
+ reviewType: string,
194
+ stepNum: number,
195
+ ): boolean {
196
+ try {
197
+ if (!existsSync(reviewsDir)) return false;
198
+ const latest = latestReviewFilesPerGate(readdirSync(reviewsDir));
199
+ const filename = latest.get(`${reviewType.toLowerCase()}-step${stepNum}`);
200
+ if (!filename) return false;
201
+ const verdict = parseReviewVerdict(readFileSync(join(reviewsDir, filename), "utf-8"));
202
+ return verdict === "REVISE" || verdict === "RETHINK";
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
177
208
  export function isStepMarkedComplete(statusPath: string, stepNum: number): boolean {
178
209
  let content: string;
179
210
  try {
@@ -822,7 +853,18 @@ export default function (pi: ExtensionAPI) {
822
853
  // violated the Order of Operations contract; the only safe path
823
854
  // is to revert STATUS first, then re-call review_step. Plan
824
855
  // reviews are exempt because they fire BEFORE implementation.
825
- if (reviewType !== "plan" && isStepMarkedComplete(statusPath, stepNum)) {
856
+ //
857
+ // #629 exemption: when the step is Complete but the LATEST review for
858
+ // this gate is REVISE/RETHINK, the runtime's finalize gate has refused
859
+ // (or will refuse) the task and spawned a remediation iteration whose
860
+ // whole purpose is to re-run this review. That state IS the anomaly
861
+ // being repaired — a re-review is the correct action, not an order
862
+ // violation. Refusing here would make the documented remedy unreachable.
863
+ if (
864
+ reviewType !== "plan" &&
865
+ isStepMarkedComplete(statusPath, stepNum) &&
866
+ !hasOutstandingNonApproveReview(reviewsDir, reviewType, stepNum)
867
+ ) {
826
868
  const taskIdMatch = statusPath.match(/[\\/]([A-Z]{2,}-\d+)[^\\/]*[\\/]STATUS\.md$/);
827
869
  const taskId = taskIdMatch ? taskIdMatch[1] : "<TASK-ID>";
828
870
  const refusal = [
@@ -944,13 +986,23 @@ export default function (pi: ExtensionAPI) {
944
986
  // Read review output and extract verdict
945
987
  if (existsSync(outputPath)) {
946
988
  const reviewContent = readFileSync(outputPath, "utf-8");
947
- const verdictMatch = reviewContent.match(/###?\s*Verdict[:\s]*(APPROVE|REVISE|RETHINK)/i);
948
- let verdict = verdictMatch ? verdictMatch[1].toUpperCase() : "UNKNOWN";
989
+ // #624 (severity upgrade): robust, FAIL-CLOSED verdict extraction. The
990
+ // old regex ('###?\s*Verdict[:\s]*…') missed common reviewer format
991
+ // variants, and its fallback checked the substring "approve" FIRST — so
992
+ // a REVISE review whose body merely contained "approve" (e.g. "cannot
993
+ // approve") was returned to the worker as APPROVE. Workers then marked
994
+ // steps complete and advanced past unaddressed findings. The review
995
+ // gate must NEVER fail open: APPROVE is only ever taken from an
996
+ // explicit Verdict line (parseReviewVerdict handles heading/bold/plain/
997
+ // dash/bracket variants and skips the template placeholder). The body
998
+ // fallback below may only produce fail-closed guesses (REVISE/RETHINK).
999
+ const parsedVerdict = parseReviewVerdict(reviewContent);
1000
+ let verdict: string = parsedVerdict ?? "UNKNOWN";
949
1001
  if (verdict === "UNKNOWN") {
950
1002
  const lower = reviewContent.toLowerCase();
951
- if (lower.includes("approve") && !lower.includes("do not approve")) verdict = "APPROVE";
952
- else if (lower.includes("revise") || lower.includes("changes requested")) verdict = "REVISE";
953
- else if (lower.includes("rethink")) verdict = "RETHINK";
1003
+ if (/\brevise\b/.test(lower) || lower.includes("changes requested")) verdict = "REVISE";
1004
+ else if (/\brethink\b/.test(lower)) verdict = "RETHINK";
1005
+ // NO approve fallback an approval must be explicit.
954
1006
  }
955
1007
 
956
1008
  // Log review in STATUS.md execution log
@@ -986,7 +1038,13 @@ export default function (pi: ExtensionAPI) {
986
1038
  } else {
987
1039
  return {
988
1040
  content: [
989
- { type: "text" as const, text: `Review complete (verdict unclear). See ${reviewFile}` },
1041
+ {
1042
+ type: "text" as const,
1043
+ text:
1044
+ `Review complete (verdict unclear). Read ${reviewFile} and follow its ` +
1045
+ `Verdict line — do NOT treat this as an approval or mark the step complete ` +
1046
+ `without an explicit APPROVE.`,
1047
+ },
990
1048
  ],
991
1049
  details: undefined,
992
1050
  };
@@ -40,6 +40,7 @@ import type {
40
40
  RuntimeAgentEventType,
41
41
  RuntimeAgentManifest,
42
42
  PacketPaths,
43
+ ReviewDisposition,
43
44
  } from "./types.ts";
44
45
 
45
46
  import {
@@ -144,6 +145,65 @@ export function buildWorkerToolsAllowlist(userTools: string | undefined | null):
144
145
  return Array.from(merged).join(",");
145
146
  }
146
147
 
148
+ /**
149
+ * Normalize the freeform text a `review_step` tool call returns into a
150
+ * {@link ReviewDisposition}. The tool returns clean leading tokens
151
+ * (`APPROVE`, `REVISE: …`, `RETHINK — …`, `UNAVAILABLE — …`, `REFUSED: …`),
152
+ * but this parser is defensive: it matches a leading verdict token
153
+ * case-insensitively and falls back to a substring scan, returning `UNKNOWN`
154
+ * when nothing recognizable is present.
155
+ *
156
+ * Pure and side-effect-free so it can be unit-tested directly.
157
+ *
158
+ * @since review-boundary notifications
159
+ */
160
+ /**
161
+ * Extract the review file path from a review_step tool return. The tool appends
162
+ * "Full review: .reviews/R{NNN}-{type}-step{N}.md" (REVISE) or
163
+ * "See .reviews/R{NNN}-{type}-step{N}.md" (RETHINK). Returns undefined when no
164
+ * such reference is present (e.g. APPROVE returns just "APPROVE").
165
+ */
166
+ export function extractReviewPath(resultText: string | undefined | null): string | undefined {
167
+ if (!resultText || typeof resultText !== "string") return undefined;
168
+ const m = resultText.match(/(?:Full review:|See)\s+(\S*R\d+-[a-z]+-step\d+\.md)\b/i);
169
+ if (m) return m[1];
170
+ const m2 = resultText.match(/(\S*R\d+-[a-z]+-step\d+\.md)\b/i);
171
+ return m2 ? m2[1] : undefined;
172
+ }
173
+
174
+ export function normalizeReviewDisposition(
175
+ resultText: string | undefined | null,
176
+ ): ReviewDisposition {
177
+ if (!resultText || typeof resultText !== "string") return "UNKNOWN";
178
+ const text = resultText.trim();
179
+ if (text.length === 0) return "UNKNOWN";
180
+ // Leading-token match (the tool's canonical output shape).
181
+ const lead = text.toUpperCase();
182
+ if (/^APPROVE\b/.test(lead)) return "APPROVE";
183
+ if (/^REVISE\b/.test(lead)) return "REVISE";
184
+ if (/^RETHINK\b/.test(lead)) return "RETHINK";
185
+ if (/^REFUSED\b/.test(lead)) return "REFUSED";
186
+ if (/^UNAVAILABLE\b/.test(lead)) return "UNAVAILABLE";
187
+ // Defensive fallback: scan for the token anywhere, most-specific first, using
188
+ // word boundaries so substrings inside other words don't false-match.
189
+ // REFUSED and UNAVAILABLE are checked before REVISE/RETHINK because their
190
+ // bodies may quote a verdict word.
191
+ if (/\bREFUSED\b/.test(lead)) return "REFUSED";
192
+ if (/\bUNAVAILABLE\b/.test(lead)) return "UNAVAILABLE";
193
+ if (/\bRETHINK\b/.test(lead)) return "RETHINK";
194
+ if (/\bREVISE\b/.test(lead) || /\bCHANGES REQUESTED\b/.test(lead)) return "REVISE";
195
+ // Approve only on a clean, non-negated APPROVE token. Reject explicit
196
+ // negations ("do not approve", "not approved", "disapprove", "unapproved").
197
+ if (
198
+ /\bAPPROVE\b/.test(lead) &&
199
+ !/\bDO NOT APPROVE\b/.test(lead) &&
200
+ !/\bNOT APPROVE\b/.test(lead)
201
+ ) {
202
+ return "APPROVE";
203
+ }
204
+ return "UNKNOWN";
205
+ }
206
+
147
207
  // ── Conversation Payload Helpers (TP-111) ───────────────────────────────
148
208
 
149
209
  /** Maximum characters for conversation event text payloads. */
@@ -181,6 +241,37 @@ function extractAssistantText(message: Record<string, unknown>): string {
181
241
  return "";
182
242
  }
183
243
 
244
+ /**
245
+ * Extract text from a Pi RPC `tool_execution_end` result, which — like message
246
+ * content — may be a plain string, an array of `{type:"text",text}` blocks, or
247
+ * an object with a `content` field (the shape tool handlers return). The old
248
+ * `typeof event.result === "string" ? ... : String(event.output)` extraction
249
+ * silently produced an empty/garbage string for structured results, which made
250
+ * review_step verdicts unparseable and mis-fired "Reviewer unavailable" (#624).
251
+ */
252
+ export function extractToolResultText(event: { result?: unknown; output?: unknown }): string {
253
+ const fromValue = (val: unknown): string | null => {
254
+ if (typeof val === "string") return val;
255
+ if (Array.isArray(val)) {
256
+ const texts = val
257
+ .filter(
258
+ (b: unknown): b is { type: string; text: string } =>
259
+ typeof b === "object" &&
260
+ b !== null &&
261
+ (b as { type?: unknown }).type === "text" &&
262
+ typeof (b as { text?: unknown }).text === "string",
263
+ )
264
+ .map((b) => b.text);
265
+ if (texts.length > 0) return texts.join("\n");
266
+ }
267
+ if (val && typeof val === "object" && "content" in val) {
268
+ return extractAssistantText(val as Record<string, unknown>);
269
+ }
270
+ return null;
271
+ };
272
+ return fromValue(event.result) ?? fromValue(event.output) ?? "";
273
+ }
274
+
184
275
  // ── Types ────────────────────────────────────────────────────────────
185
276
 
186
277
  /**
@@ -250,6 +341,11 @@ export interface AgentHostOptions {
250
341
  * @since TP-172
251
342
  */
252
343
  maxExitInterceptions?: number;
344
+ /**
345
+ * Upper bound (ms) for one onPrematureExit intercept before the host stops
346
+ * waiting. Must exceed the lane's supervisor-reply window; default 120s.
347
+ */
348
+ exitInterceptSafetyMs?: number;
253
349
  }
254
350
 
255
351
  /**
@@ -396,6 +492,14 @@ export function spawnAgent(
396
492
  let lastTool = "",
397
493
  error: string | null = null;
398
494
  let contextUsage: AgentHostResult["contextUsage"] = null;
495
+ /**
496
+ * In-flight review_step boundary state (review-boundary notifications). Set at
497
+ * tool_execution_start so the end event and any crash-abort can carry the
498
+ * step/reviewType identity (tool_execution_end does not include the original
499
+ * args). review_step calls are sequential within a worker (the worker blocks
500
+ * on the verdict), so a single pending slot is sufficient.
501
+ */
502
+ let pendingReview: { step?: number; reviewType?: string } | null = null;
399
503
  let stderrBuffer = "";
400
504
  const STDERR_MAX = 2048;
401
505
  /** Last assistant message text captured from message_end events (TP-172) */
@@ -589,7 +693,10 @@ export function spawnAgent(
589
693
  try {
590
694
  appendFileSync(
591
695
  opts.steeringPendingPath,
592
- JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id }) + "\n",
696
+ // #630: `type` lets the lane-runner tell an acknowledgement (info)
697
+ // from a ruling/instruction (steer) for hold bookkeeping.
698
+ JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id, type: msg.type }) +
699
+ "\n",
593
700
  "utf-8",
594
701
  );
595
702
  } catch {
@@ -671,6 +778,19 @@ export function spawnAgent(
671
778
  : exitCode === 0 && agentEnded
672
779
  ? "agent_exited"
673
780
  : "agent_crashed";
781
+ // Review-boundary: if the worker died mid-review, close the dangling
782
+ // review_started so the supervisor doesn't see an orphaned "review
783
+ // starting" with no end. Emitted as review_failed (aborted) before the
784
+ // terminal exit event.
785
+ if (pendingReview) {
786
+ emitEvent("review_failed", {
787
+ step: pendingReview.step,
788
+ reviewType: pendingReview.reviewType,
789
+ disposition: "UNKNOWN",
790
+ summary: `review aborted (${exitEventType})`,
791
+ });
792
+ pendingReview = null;
793
+ }
674
794
  emitEvent(exitEventType, { exitCode, signal, durationMs: result.durationMs, timedOut });
675
795
 
676
796
  // Registry integration: update manifest to terminal status
@@ -781,17 +901,58 @@ export function spawnAgent(
781
901
  // TP-111: Bounded payload only — no raw args in durable event log
782
902
  const toolPath = event.args?.path ? String(event.args.path).slice(0, 200) : "";
783
903
  emitEvent("tool_call", { tool: toolName, path: toolPath, argsPreview: argPreview });
904
+ // Review-boundary notification: the review START. review_step spawns a
905
+ // reviewer; surfacing this lets the supervisor track review activity
906
+ // live (see lane-runner onEvent bridge + supervisor tailer).
907
+ if (toolName === "review_step") {
908
+ const reviewStep =
909
+ event.args && typeof event.args === "object"
910
+ ? (event.args as { step?: unknown }).step
911
+ : undefined;
912
+ const reviewType =
913
+ event.args && typeof event.args === "object"
914
+ ? (event.args as { type?: unknown }).type
915
+ : undefined;
916
+ const stepNum = typeof reviewStep === "number" ? reviewStep : undefined;
917
+ const rType = typeof reviewType === "string" ? reviewType : undefined;
918
+ // Remember identity so the end/abort events can carry step+reviewType
919
+ // (tool_execution_end omits args).
920
+ pendingReview = { step: stepNum, reviewType: rType };
921
+ emitEvent("review_requested", { step: stepNum, reviewType: rType });
922
+ }
784
923
  break;
785
924
  }
786
925
  case "tool_execution_end": {
787
- // TP-111: Include bounded result summary for dashboard display
788
- const toolResultSummary =
789
- typeof event.result === "string"
790
- ? event.result.slice(0, 200)
791
- : event.output
792
- ? String(event.output).slice(0, 200)
793
- : "";
926
+ // #624: extract robustly tool results are often structured content
927
+ // arrays, not plain strings; the old extraction produced "" for those.
928
+ const fullResult = extractToolResultText(event);
929
+ const toolResultSummary = fullResult.slice(0, 200);
794
930
  emitEvent("tool_result", { tool: event.toolName, summary: toolResultSummary });
931
+ // Review-boundary notification: the review END. Normalize the reviewer
932
+ // verdict and emit review_completed (APPROVE/REVISE/RETHINK/REFUSED) or
933
+ // review_failed. Only a GENUINE UNAVAILABLE (reviewer subprocess failed
934
+ // / produced no output) is the "broken reviewer" signal. A parse miss
935
+ // (UNKNOWN) must NOT masquerade as a broken reviewer (#624) — emit it as
936
+ // review_completed; lane-runner authoritatively resolves the verdict from
937
+ // the review file on disk.
938
+ if (event.toolName === "review_step") {
939
+ const disposition = normalizeReviewDisposition(fullResult);
940
+ const reviewFields = {
941
+ step: pendingReview?.step,
942
+ reviewType: pendingReview?.reviewType,
943
+ disposition,
944
+ summary: toolResultSummary,
945
+ // review_step embeds the review file path in its REVISE/RETHINK
946
+ // return; surfacing it lets lane-runner read the EXACT review file.
947
+ reviewPath: extractReviewPath(fullResult),
948
+ };
949
+ if (disposition === "UNAVAILABLE") {
950
+ emitEvent("review_failed", reviewFields);
951
+ } else {
952
+ emitEvent("review_completed", reviewFields);
953
+ }
954
+ pendingReview = null;
955
+ }
795
956
  break;
796
957
  }
797
958
  case "auto_retry_start": {
@@ -841,7 +1002,7 @@ export function spawnAgent(
841
1002
  const shouldIntercept = opts.onPrematureExit && exitInterceptionCount < maxExitInterceptions;
842
1003
  if (shouldIntercept) {
843
1004
  exitInterceptionCount++;
844
- const INTERCEPTION_TIMEOUT_MS = 120_000; // 2 minute safety timeout
1005
+ const INTERCEPTION_TIMEOUT_MS = opts.exitInterceptSafetyMs ?? 120_000; // safety timeout (> lane window)
845
1006
  // Wrap in Promise.resolve().then() to catch synchronous throws
846
1007
  const interceptPromise = Promise.resolve().then(() =>
847
1008
  opts.onPrematureExit!(lastAssistantMessage),
@@ -1268,6 +1268,9 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
1268
1268
  thinking: config.taskRunner.worker.thinking,
1269
1269
  tools: config.taskRunner.worker.tools,
1270
1270
  excludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
1271
+ ...(typeof config.taskRunner.worker.exitInterceptTimeoutSec === "number"
1272
+ ? { exitInterceptTimeoutSec: config.taskRunner.worker.exitInterceptTimeoutSec }
1273
+ : {}),
1271
1274
  },
1272
1275
  model_fallback: config.taskRunner.modelFallback ?? "inherit",
1273
1276
  reviewer: {
@@ -1275,6 +1278,12 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
1275
1278
  thinking: config.taskRunner.reviewer.thinking,
1276
1279
  tools: config.taskRunner.reviewer.tools,
1277
1280
  excludeExtensions: [...(config.taskRunner.reviewer.excludeExtensions ?? [])],
1281
+ ...(config.taskRunner.reviewer.severityLabels
1282
+ ? { severityLabels: [...config.taskRunner.reviewer.severityLabels] }
1283
+ : {}),
1284
+ ...(config.taskRunner.reviewer.spiral
1285
+ ? { spiral: { ...config.taskRunner.reviewer.spiral } }
1286
+ : {}),
1278
1287
  },
1279
1288
  workerExcludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
1280
1289
  };
@@ -118,9 +118,34 @@ export interface WorkerConfig {
118
118
  spawnMode?: "subprocess";
119
119
  /** Package specifiers to exclude from extension forwarding for worker agents (exact match). @since TP-180 */
120
120
  excludeExtensions?: string[];
121
+ /**
122
+ * How long (seconds) the lane waits for a supervisor reply when it intercepts a
123
+ * worker's premature exit, before letting the session close. Default 60. Raise
124
+ * it when the supervisor is often inside long tool calls (a blocking
125
+ * `--wait` mid-tool-call cannot answer in 60s — penster feedback #3, item 5).
126
+ * Bounded to 15..1800.
127
+ */
128
+ exitInterceptTimeoutSec?: number;
121
129
  }
122
130
 
123
131
  /** Reviewer agent configuration */
132
+ /**
133
+ * Revision-spiral detection tuning (review-boundary supervisor notifications).
134
+ * When a step is reviewed repeatedly without converging, the supervisor is
135
+ * escalated so it can adjudicate / steer the worker. Detection is notify-based,
136
+ * not a hard cap on revisions.
137
+ */
138
+ export interface ReviewSpiralConfig {
139
+ /** Master switch for spiral escalation (per-boundary notifications are always on). */
140
+ enabled: boolean;
141
+ /** Consecutive non-APPROVE reviews on the SAME step before the first escalation. */
142
+ threshold: number;
143
+ /** Minimum further non-APPROVE reviews between re-escalations (anti-spam spacing). */
144
+ cooldownReviews: number;
145
+ /** Whether UNAVAILABLE reviews count toward the spiral (default false: broken-reviewer signal, not a spiral). */
146
+ treatUnavailableAsNonApprove: boolean;
147
+ }
148
+
124
149
  export interface ReviewerConfig {
125
150
  /** Reviewer model (empty = inherit session model) */
126
151
  model: string;
@@ -130,6 +155,15 @@ export interface ReviewerConfig {
130
155
  thinking: string;
131
156
  /** Package specifiers to exclude from extension forwarding for reviewer agents (exact match). @since TP-180 */
132
157
  excludeExtensions?: string[];
158
+ /**
159
+ * Ordered severity vocabulary (highest severity first) used to bucket review
160
+ * findings for spiral-vs-converging analysis. Core default is generic
161
+ * (critical/important/minor); projects whose reviewer emits a different scheme
162
+ * (e.g. P0/P1/P2) override this. Never hardcode a project vocabulary in core.
163
+ */
164
+ severityLabels: string[];
165
+ /** Revision-spiral detection tuning. */
166
+ spiral: ReviewSpiralConfig;
133
167
  }
134
168
 
135
169
  /** Context/resource limits for task execution */
@@ -598,7 +632,19 @@ export const DEFAULT_TASK_RUNNER_SECTION: TaskRunnerSection = {
598
632
  // bridge tools are appended at the lane-runner spawn site by
599
633
  // `buildWorkerToolsAllowlist()`, not here.
600
634
  worker: { model: "", tools: DEFAULT_WORKER_USER_TOOLS, thinking: "", excludeExtensions: [] },
601
- reviewer: { model: "", tools: "read,bash,grep,find,ls", thinking: "on", excludeExtensions: [] },
635
+ reviewer: {
636
+ model: "",
637
+ tools: "read,bash,grep,find,ls",
638
+ thinking: "on",
639
+ excludeExtensions: [],
640
+ severityLabels: ["critical", "important", "minor"],
641
+ spiral: {
642
+ enabled: true,
643
+ threshold: 3,
644
+ cooldownReviews: 2,
645
+ treatUnavailableAsNonApprove: false,
646
+ },
647
+ },
602
648
  context: {
603
649
  workerContextWindow: 0,
604
650
  warnPercent: 85,
@@ -0,0 +1,158 @@
1
+ /**
2
+ * In-flight tool_use/tool_result ordering repair — issue #621 (defense in depth).
3
+ *
4
+ * The supervisor injects `custom` display messages via pi.sendMessage(). Any
5
+ * such injection that lands while the interactive agent has a tool call in
6
+ * flight splices a message BETWEEN an assistant `tool_use` and its
7
+ * `toolResult`. Anthropic then rejects the request:
8
+ *
9
+ * 400 messages.N.content.M: unexpected `tool_use_id` found in `tool_result`
10
+ * blocks ... Each `tool_result` block must have a corresponding `tool_use`
11
+ * block in the previous message.
12
+ *
13
+ * The batch-end epilogue gate (supervisor-dispatch.ts) prevents the most common
14
+ * source, but the supervisor has many other background `pi.sendMessage(...,
15
+ * {triggerTurn:false})` sites (integration progress/result, heartbeat, routing)
16
+ * that can splice the same way. Rather than gate each one, this module repairs
17
+ * the ORDERING of the outgoing message array on the pi `context` event, which
18
+ * fires before every provider request (`transformContext`, on the pi-internal
19
+ * AgentMessage[] before convertToLlm). Each assistant's tool results are pulled
20
+ * to immediately follow it (in tool-call order); any spliced-in `custom`/`user`
21
+ * messages move to after the tool-result group. The request is therefore always
22
+ * valid regardless of where a stray message was appended, and a mistimed
23
+ * injection can never wedge the session.
24
+ *
25
+ * This does not mutate the persisted session tree — it only transforms the
26
+ * per-request context — so it is safe, idempotent, and self-correcting across
27
+ * reloads.
28
+ */
29
+
30
+ interface ToolCallBlock {
31
+ type: string;
32
+ id?: string;
33
+ [key: string]: unknown;
34
+ }
35
+ interface AgentMessageLike {
36
+ role?: string;
37
+ content?: unknown;
38
+ toolCallId?: string;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ function toolUseIds(msg: AgentMessageLike): string[] {
43
+ if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return [];
44
+ const ids: string[] = [];
45
+ for (const block of msg.content as ToolCallBlock[]) {
46
+ if (
47
+ block &&
48
+ typeof block === "object" &&
49
+ block.type === "toolCall" &&
50
+ typeof block.id === "string"
51
+ ) {
52
+ ids.push(block.id);
53
+ }
54
+ }
55
+ return ids;
56
+ }
57
+
58
+ /**
59
+ * Reorder `messages` so every assistant `tool_use` is immediately followed by
60
+ * its matching `toolResult`(s), relocating any spliced-in non-tool messages to
61
+ * after the tool-result group.
62
+ *
63
+ * Robustness (Sage #621 review):
64
+ * - Duplicate `toolResult` messages sharing a `toolCallId` are all preserved
65
+ * (queue-based grouping, not last-wins), so repair never drops data.
66
+ * - Emitted results are tracked by message identity, not by id.
67
+ * - Also repairs the result-before-assistant shape: a `toolResult` whose owning
68
+ * assistant appears later is held and pulled forward at the owner.
69
+ * - A final safety-net pass appends any never-emitted result, guaranteeing no
70
+ * `toolResult` is ever lost regardless of input malformation.
71
+ *
72
+ * Returns the SAME array reference when already well-formed (no reordering
73
+ * needed), so callers can cheaply detect a no-op. Otherwise returns a new,
74
+ * reordered array. Pure: never mutates the input array or its elements.
75
+ */
76
+ export function repairToolResultOrdering<T extends AgentMessageLike>(messages: T[]): T[] {
77
+ if (!Array.isArray(messages) || messages.length < 3) return messages;
78
+
79
+ // Collect ALL toolResult messages per toolCallId, preserving original order.
80
+ // A queue (array) rather than last-wins so duplicate results for the same id
81
+ // are never dropped (Sage #621 review: last-wins could silently lose data).
82
+ const resultsById = new Map<string, T[]>();
83
+ for (const m of messages) {
84
+ if (m && m.role === "toolResult" && typeof m.toolCallId === "string") {
85
+ const list = resultsById.get(m.toolCallId);
86
+ if (list) list.push(m);
87
+ else resultsById.set(m.toolCallId, [m]);
88
+ }
89
+ }
90
+ if (resultsById.size === 0) return messages;
91
+
92
+ // First-occurrence index of the assistant that owns each toolCallId. Lets us
93
+ // HOLD an in-place toolResult whose owning assistant appears LATER, repairing
94
+ // the result-before-assistant shape (Sage #621 review) instead of emitting it
95
+ // in a position that would still be invalid.
96
+ const ownerIndexById = new Map<string, number>();
97
+ for (let i = 0; i < messages.length; i++) {
98
+ for (const id of toolUseIds(messages[i])) {
99
+ if (!ownerIndexById.has(id)) ownerIndexById.set(id, i);
100
+ }
101
+ }
102
+
103
+ const out: T[] = [];
104
+ // Track emitted results by message IDENTITY, not by id, so duplicate result
105
+ // messages sharing a toolCallId are each accounted for individually.
106
+ const emitted = new Set<T>();
107
+
108
+ for (let i = 0; i < messages.length; i++) {
109
+ const m = messages[i];
110
+ if (m && m.role === "toolResult" && typeof m.toolCallId === "string") {
111
+ if (emitted.has(m)) continue; // already pulled forward next to its assistant
112
+ const owner = ownerIndexById.get(m.toolCallId);
113
+ // Owner appears later → hold; it will be pulled forward at the owner.
114
+ // Owner earlier (normal splice case) or orphan (no owner) → emit in place.
115
+ if (owner !== undefined && owner > i) continue;
116
+ out.push(m);
117
+ emitted.add(m);
118
+ continue;
119
+ }
120
+
121
+ out.push(m);
122
+
123
+ // Pull every matching toolResult (all of them, in original order) to
124
+ // immediately follow this assistant, in tool-call order.
125
+ for (const id of toolUseIds(m)) {
126
+ const list = resultsById.get(id);
127
+ if (!list) continue; // genuinely unanswered tool_use — not repairable here
128
+ for (const result of list) {
129
+ if (emitted.has(result)) continue;
130
+ out.push(result);
131
+ emitted.add(result);
132
+ }
133
+ }
134
+ }
135
+
136
+ // Safety net: guarantee no toolResult is ever dropped. Any result not emitted
137
+ // above (only reachable via a held-but-never-pulled edge case) is appended in
138
+ // original order. Guarded by identity so it can never double-emit.
139
+ for (const m of messages) {
140
+ if (m && m.role === "toolResult" && typeof m.toolCallId === "string" && !emitted.has(m)) {
141
+ out.push(m);
142
+ emitted.add(m);
143
+ }
144
+ }
145
+
146
+ // Return the original reference when nothing moved (cheap no-op detection).
147
+ if (out.length === messages.length) {
148
+ let identical = true;
149
+ for (let i = 0; i < out.length; i++) {
150
+ if (out[i] !== messages[i]) {
151
+ identical = false;
152
+ break;
153
+ }
154
+ }
155
+ if (identical) return messages;
156
+ }
157
+ return out;
158
+ }