@mrclrchtr/supi-review 3.0.0 → 3.1.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/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/package.json +2 -2
- package/src/git.ts +16 -13
- package/src/review.ts +58 -21
- package/src/tool/agent-review-schemas.ts +14 -7
- package/src/tool/agent-review-tools.ts +20 -3
- package/src/tool/child-failure-diagnostics.ts +58 -0
- package/src/tool/child-lifecycle-trace.ts +32 -3
- package/src/tool/review-workflow.ts +18 -2
- package/src/tui/common.ts +63 -1
- package/src/types.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-review",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "SuPi Review extension — caller-defined read-only review tasks for users and agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"README.md"
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@mrclrchtr/supi-core": "3.
|
|
34
|
+
"@mrclrchtr/supi-core": "3.1.0"
|
|
35
35
|
},
|
|
36
36
|
"bundledDependencies": [
|
|
37
37
|
"@mrclrchtr/supi-core"
|
package/src/git.ts
CHANGED
|
@@ -17,7 +17,6 @@ import type {
|
|
|
17
17
|
ReviewTargetSpec,
|
|
18
18
|
} from "./types.ts";
|
|
19
19
|
|
|
20
|
-
const FULL_COMMIT_ID_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
21
20
|
const DIFF_FLAGS = ["--no-ext-diff", "--no-textconv", "--binary"] as const;
|
|
22
21
|
|
|
23
22
|
function parseNullList(text: string): string[] {
|
|
@@ -40,17 +39,12 @@ export function parseDiffStats(text: string, fileCount?: number): DiffStats {
|
|
|
40
39
|
return { files: fileCount ?? files, additions, deletions };
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
/** Validate the agent-facing full Git object-id syntax before invoking Git. */
|
|
44
|
-
export function isFullCommitId(value: string): boolean {
|
|
45
|
-
return FULL_COMMIT_ID_RE.test(value);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
42
|
async function resolveCommit(cwd: string, value: string): Promise<string | undefined> {
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
const canonical = (await gitAllowExit(cwd, ["rev-parse", "--verify", value], [1, 128])).trim();
|
|
44
|
+
if (!canonical) return undefined;
|
|
45
|
+
const type = (await gitAllowExit(cwd, ["cat-file", "-t", canonical], [128])).trim();
|
|
51
46
|
if (type !== "commit") return undefined;
|
|
52
|
-
|
|
53
|
-
return canonical === value.toLowerCase() ? canonical : undefined;
|
|
47
|
+
return canonical.toLowerCase();
|
|
54
48
|
}
|
|
55
49
|
|
|
56
50
|
async function headCommit(cwd: string): Promise<string | undefined> {
|
|
@@ -131,9 +125,16 @@ async function comparisonSnapshot(
|
|
|
131
125
|
resolveCommit(cwd, requested.baseCommit),
|
|
132
126
|
headCommit(cwd),
|
|
133
127
|
]);
|
|
134
|
-
if (!base
|
|
128
|
+
if (!base) {
|
|
129
|
+
throw new Error(`Commit ${requested.baseCommit.slice(0, 7)} not found in this repository.`);
|
|
130
|
+
}
|
|
131
|
+
if (!head) {
|
|
132
|
+
throw new Error("No HEAD commit found in this repository.");
|
|
133
|
+
}
|
|
135
134
|
const mergeBase = (await gitAllowExit(cwd, ["merge-base", base, head], [1])).trim();
|
|
136
|
-
if (!mergeBase)
|
|
135
|
+
if (!mergeBase) {
|
|
136
|
+
throw new Error(`No common ancestor between ${base.slice(0, 7)} and ${head.slice(0, 7)}.`);
|
|
137
|
+
}
|
|
137
138
|
const [diffText, names] = await Promise.all([
|
|
138
139
|
git(cwd, ["diff", ...DIFF_FLAGS, mergeBase, head]),
|
|
139
140
|
git(cwd, ["diff", "--name-only", "-z", mergeBase, head]),
|
|
@@ -160,7 +161,9 @@ async function commitSnapshot(
|
|
|
160
161
|
requested: Extract<ReviewTargetSpec, { kind: "commit" }>,
|
|
161
162
|
): Promise<ReviewSnapshot | undefined> {
|
|
162
163
|
const commit = await resolveCommit(cwd, requested.commit);
|
|
163
|
-
if (!commit)
|
|
164
|
+
if (!commit) {
|
|
165
|
+
throw new Error(`Commit ${requested.commit.slice(0, 7)} not found in this repository.`);
|
|
166
|
+
}
|
|
164
167
|
const parent = await commitParent(cwd, commit);
|
|
165
168
|
const [diffText, names] = await Promise.all([
|
|
166
169
|
parent
|
package/src/review.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { buildSessionContext, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Box } from "@earendil-works/pi-tui";
|
|
3
|
-
import { Value } from "typebox/value";
|
|
4
3
|
import { loadReviewConfig, registerReviewSettings } from "./config.ts";
|
|
5
4
|
import { listLocalBranches, listRecentCommits } from "./git.ts";
|
|
6
5
|
import { collectPlannerContext } from "./history/collect.ts";
|
|
@@ -8,8 +7,7 @@ import { getSelectableReviewModels, resolveAgentReviewModel } from "./model.ts";
|
|
|
8
7
|
import { ReviewPlanStore } from "./session/review-plan-store.ts";
|
|
9
8
|
import { formatReviewBatch, registerAgentReviewTools } from "./tool/agent-review-tools.ts";
|
|
10
9
|
import { pageText } from "./tool/output-page.ts";
|
|
11
|
-
import {
|
|
12
|
-
import { reviewInputSchema } from "./tool/schemas.ts";
|
|
10
|
+
import { prepareReview, runReview } from "./tool/review-workflow.ts";
|
|
13
11
|
import { renderRunResult } from "./tui/run.ts";
|
|
14
12
|
import type { ReviewInput, ReviewModelSelection, ReviewTargetSpec } from "./types.ts";
|
|
15
13
|
|
|
@@ -58,25 +56,59 @@ async function selectReviewerModel(ctx: CommandContext): Promise<ReviewModelSele
|
|
|
58
56
|
return models.find((model) => model.canonicalId === id);
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
59
|
+
/** Edit one task's instructions via a focused editor. Returns undefined on cancel. */
|
|
60
|
+
async function editTaskInstructions(
|
|
61
|
+
ctx: CommandContext,
|
|
62
|
+
opts: { index: number; taskCount: number; id: string; defaultInstructions: string },
|
|
63
|
+
): Promise<string | undefined> {
|
|
64
|
+
const { index, taskCount, id, defaultInstructions } = opts;
|
|
65
|
+
const label = taskCount > 1 ? `Task ${index + 1} of ${taskCount}: ${id}` : `Task: ${id}`;
|
|
66
|
+
const instructions = await ctx.ui.editor(label, defaultInstructions);
|
|
67
|
+
if (instructions === undefined) return undefined;
|
|
68
|
+
if (!instructions.trim()) {
|
|
69
|
+
ctx.ui.notify("Task instructions must not be blank.", "error");
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return instructions.trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Step-by-step wizard: edit each task's instructions in its own focused editor. */
|
|
76
|
+
async function editReviewInteractive(
|
|
63
77
|
ctx: CommandContext,
|
|
64
78
|
review: ReviewInput,
|
|
79
|
+
{ allowResize }: { allowResize: boolean },
|
|
65
80
|
): Promise<ReviewInput | undefined> {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
71
|
-
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
return normalizeReviewInput(parsed);
|
|
75
|
-
} catch (error) {
|
|
76
|
-
const message = error instanceof Error ? error.message : "Review tasks must be valid JSON.";
|
|
77
|
-
ctx.ui.notify(message, "error");
|
|
78
|
-
return undefined;
|
|
81
|
+
let taskCount = review.tasks.length;
|
|
82
|
+
|
|
83
|
+
if (allowResize) {
|
|
84
|
+
const countStr = await ctx.ui.select("How many review tasks?", ["1", "2", "3", "4"]);
|
|
85
|
+
if (!countStr) return undefined;
|
|
86
|
+
taskCount = Number(countStr);
|
|
79
87
|
}
|
|
88
|
+
|
|
89
|
+
const tasks: ReviewInput["tasks"] = [];
|
|
90
|
+
for (let i = 0; i < taskCount; i++) {
|
|
91
|
+
const existing = review.tasks[i];
|
|
92
|
+
const id = existing?.id ?? `task-${i + 1}`;
|
|
93
|
+
const defaultInstructions = existing?.instructions ?? "";
|
|
94
|
+
|
|
95
|
+
const instructions = await editTaskInstructions(ctx, {
|
|
96
|
+
index: i,
|
|
97
|
+
taskCount,
|
|
98
|
+
id,
|
|
99
|
+
defaultInstructions,
|
|
100
|
+
});
|
|
101
|
+
if (instructions === undefined) return undefined;
|
|
102
|
+
tasks.push({ id, instructions });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result: ReviewInput = { tasks };
|
|
106
|
+
if (review.sharedContext) {
|
|
107
|
+
const sharedContext = await ctx.ui.editor("Shared context (optional)", review.sharedContext);
|
|
108
|
+
if (sharedContext === undefined) return undefined;
|
|
109
|
+
if (sharedContext.trim()) result.sharedContext = sharedContext.trim();
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
80
112
|
}
|
|
81
113
|
|
|
82
114
|
async function runCommand(
|
|
@@ -89,12 +121,15 @@ async function runCommand(
|
|
|
89
121
|
if (!target) return;
|
|
90
122
|
const reviewerModel = await selectReviewerModel(ctx);
|
|
91
123
|
if (!reviewerModel) return;
|
|
92
|
-
const planning = await ctx.ui.select("Review planning", [
|
|
124
|
+
const planning = await ctx.ui.select("Review planning", [
|
|
125
|
+
"Write my own tasks",
|
|
126
|
+
"AI suggests tasks",
|
|
127
|
+
]);
|
|
93
128
|
if (!planning) return;
|
|
94
129
|
|
|
95
130
|
let review = DEFAULT_REVIEW;
|
|
96
131
|
let planId: string | undefined;
|
|
97
|
-
if (planning === "
|
|
132
|
+
if (planning === "AI suggests tasks") {
|
|
98
133
|
const config = loadReviewConfig(ctx.cwd);
|
|
99
134
|
const plannerModel = resolveAgentReviewModel(ctx, config.plannerModel);
|
|
100
135
|
if (!plannerModel) {
|
|
@@ -122,7 +157,9 @@ async function runCommand(
|
|
|
122
157
|
planId = outcome.plan.id;
|
|
123
158
|
}
|
|
124
159
|
|
|
125
|
-
const edited = await
|
|
160
|
+
const edited = await editReviewInteractive(ctx, review, {
|
|
161
|
+
allowResize: planning === "Write my own tasks",
|
|
162
|
+
});
|
|
126
163
|
if (!edited) return;
|
|
127
164
|
const approved = await ctx.ui.confirm(
|
|
128
165
|
"Run review?",
|
|
@@ -5,10 +5,11 @@ import type { ReviewInput, ReviewTargetSpec } from "../types.ts";
|
|
|
5
5
|
import { reviewInputSchema } from "./schemas.ts";
|
|
6
6
|
|
|
7
7
|
const commitId = Type.String({
|
|
8
|
-
minLength:
|
|
8
|
+
minLength: 7,
|
|
9
9
|
maxLength: 64,
|
|
10
|
-
pattern: "^
|
|
11
|
-
description:
|
|
10
|
+
pattern: "^[0-9a-fA-F]{7,64}$",
|
|
11
|
+
description:
|
|
12
|
+
"Git commit hash (7-64 hex characters). Short hashes are resolved to full commit ids.",
|
|
12
13
|
});
|
|
13
14
|
const targetSchema = Type.Object(
|
|
14
15
|
{
|
|
@@ -73,16 +74,22 @@ function parseTarget(input: {
|
|
|
73
74
|
baseCommit?: string;
|
|
74
75
|
commit?: string;
|
|
75
76
|
}): ReviewTargetSpec {
|
|
76
|
-
if (input.kind === "working-tree"
|
|
77
|
+
if (input.kind === "working-tree") {
|
|
77
78
|
return { kind: "working-tree" };
|
|
78
79
|
}
|
|
79
|
-
if (input.kind === "comparison"
|
|
80
|
+
if (input.kind === "comparison") {
|
|
81
|
+
if (!input.baseCommit) {
|
|
82
|
+
throw new Error(`Comparison targets require a baseCommit (full 40- or 64-char commit id).`);
|
|
83
|
+
}
|
|
80
84
|
return { kind: "comparison", baseCommit: input.baseCommit };
|
|
81
85
|
}
|
|
82
|
-
if (input.kind === "commit"
|
|
86
|
+
if (input.kind === "commit") {
|
|
87
|
+
if (!input.commit) {
|
|
88
|
+
throw new Error(`Commit targets require a commit field (full 40- or 64-char commit id).`);
|
|
89
|
+
}
|
|
83
90
|
return { kind: "commit", commit: input.commit };
|
|
84
91
|
}
|
|
85
|
-
throw new Error(`
|
|
92
|
+
throw new Error(`Unknown target kind "${input.kind}".`);
|
|
86
93
|
}
|
|
87
94
|
|
|
88
95
|
/** Validate and narrow preparation input after provider-level JSON parsing. */
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
prepareReviewSchema,
|
|
16
16
|
runReviewSchema,
|
|
17
17
|
} from "./agent-review-schemas.ts";
|
|
18
|
+
import { formatChildFailureDiagnostics } from "./child-failure-diagnostics.ts";
|
|
18
19
|
import { pageText } from "./output-page.ts";
|
|
19
20
|
import { prepareReview, runReview } from "./review-workflow.ts";
|
|
20
21
|
|
|
@@ -54,10 +55,26 @@ function formatTaskResult(result: ReviewTaskResult): string[] {
|
|
|
54
55
|
`Model: ${result.modelId}`,
|
|
55
56
|
`Packet SHA-256: ${result.packetHash}`,
|
|
56
57
|
];
|
|
57
|
-
if (result.status === "failed")
|
|
58
|
-
|
|
58
|
+
if (result.status === "failed") {
|
|
59
|
+
lines.push(`Status: failed (${result.failureCode})`);
|
|
60
|
+
if (result.diagnostics) {
|
|
61
|
+
lines.push("", ...formatChildFailureDiagnostics(result.diagnostics));
|
|
62
|
+
}
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
if (result.status === "canceled") {
|
|
66
|
+
lines.push("Status: canceled");
|
|
67
|
+
if (result.diagnostics) {
|
|
68
|
+
lines.push("", ...formatChildFailureDiagnostics(result.diagnostics));
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
59
72
|
if (result.status === "timeout") {
|
|
60
|
-
|
|
73
|
+
lines.push(`Status: timeout (${result.timeoutMs} ms)`);
|
|
74
|
+
if (result.diagnostics) {
|
|
75
|
+
lines.push("", ...formatChildFailureDiagnostics(result.diagnostics));
|
|
76
|
+
}
|
|
77
|
+
return lines;
|
|
61
78
|
}
|
|
62
79
|
lines.push(`Verdict: ${result.verdict.toUpperCase()}`, "", result.summary);
|
|
63
80
|
for (const finding of result.findings) {
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import {
|
|
9
9
|
type ChildLifecycleTrace,
|
|
10
10
|
ChildLifecycleTraceCollector,
|
|
11
|
+
extractLastLifecycleErrorText,
|
|
11
12
|
formatChildLifecycleTrace,
|
|
12
13
|
getRegisteredToolNames,
|
|
13
14
|
toSafeAssistantStopReason,
|
|
@@ -40,6 +41,8 @@ export function buildChildFailureDiagnostics(
|
|
|
40
41
|
recentActivity: input.recentActivity.length > 0 ? [...input.recentActivity] : undefined,
|
|
41
42
|
lastAssistantStopReason: lastAssistant?.stopReason,
|
|
42
43
|
lastAssistantToolCalls: lastAssistant?.toolCalls,
|
|
44
|
+
lastAssistantErrorText: lastAssistant?.errorText,
|
|
45
|
+
lastLifecycleErrorText: extractLastLifecycleErrorText(input.lifecycleTrace),
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -95,6 +98,13 @@ export function formatChildFailureDiagnostics(diagnostics: ChildFailureDiagnosti
|
|
|
95
98
|
diagnostics.lastAssistantToolCalls && diagnostics.lastAssistantToolCalls.length > 0
|
|
96
99
|
? `- Last assistant tools: ${diagnostics.lastAssistantToolCalls.join(", ")}`
|
|
97
100
|
: undefined,
|
|
101
|
+
diagnostics.lastAssistantErrorText
|
|
102
|
+
? `- Last assistant error: ${diagnostics.lastAssistantErrorText}`
|
|
103
|
+
: undefined,
|
|
104
|
+
diagnostics.lastLifecycleErrorText &&
|
|
105
|
+
diagnostics.lastLifecycleErrorText !== diagnostics.lastAssistantErrorText
|
|
106
|
+
? `- Lifecycle error: ${diagnostics.lastLifecycleErrorText}`
|
|
107
|
+
: undefined,
|
|
98
108
|
`- ${formatChildLifecycleTrace(diagnostics.lifecycleTrace)}`,
|
|
99
109
|
];
|
|
100
110
|
return lines.filter((line): line is string => !!line);
|
|
@@ -103,6 +113,7 @@ export function formatChildFailureDiagnostics(diagnostics: ChildFailureDiagnosti
|
|
|
103
113
|
interface LastAssistantMetadata {
|
|
104
114
|
stopReason?: string;
|
|
105
115
|
toolCalls?: string[];
|
|
116
|
+
errorText?: string;
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
function extractLastAssistantMetadata(session: AgentSession): LastAssistantMetadata | undefined {
|
|
@@ -117,9 +128,11 @@ function extractLastAssistantMetadata(session: AgentSession): LastAssistantMetad
|
|
|
117
128
|
|
|
118
129
|
const stopReason = toSafeAssistantStopReason(message.stopReason);
|
|
119
130
|
const toolCalls = extractAssistantToolCalls(message.content, registeredToolNames);
|
|
131
|
+
const errorText = stopReason === "error" ? extractAssistantErrorText(message) : undefined;
|
|
120
132
|
return {
|
|
121
133
|
stopReason,
|
|
122
134
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
135
|
+
errorText,
|
|
123
136
|
};
|
|
124
137
|
}
|
|
125
138
|
return undefined;
|
|
@@ -143,3 +156,48 @@ function extractAssistantToolCalls(content: unknown, registeredToolNames: Set<st
|
|
|
143
156
|
})
|
|
144
157
|
.filter((name): name is string => !!name);
|
|
145
158
|
}
|
|
159
|
+
|
|
160
|
+
/** Extract error text from content text parts when the model embeds an error message. */
|
|
161
|
+
function extractContentErrorText(content: unknown): string | undefined {
|
|
162
|
+
if (!Array.isArray(content)) return undefined;
|
|
163
|
+
const textParts = content
|
|
164
|
+
.filter(
|
|
165
|
+
(part): part is { type: "text"; text: string } =>
|
|
166
|
+
typeof part === "object" &&
|
|
167
|
+
part !== null &&
|
|
168
|
+
(part as { type?: unknown }).type === "text" &&
|
|
169
|
+
typeof (part as { text?: unknown }).text === "string",
|
|
170
|
+
)
|
|
171
|
+
.map((part) => part.text);
|
|
172
|
+
if (textParts.length === 0) return undefined;
|
|
173
|
+
const joined = textParts.join("\n").trim();
|
|
174
|
+
return joined || undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Scan enumerable message keys for any error-like string property as a fallback. */
|
|
178
|
+
function scanMessageKeysForError(message: Record<string, unknown>): string | undefined {
|
|
179
|
+
for (const key of Object.keys(message)) {
|
|
180
|
+
if (!/error|message|reason/i.test(key)) continue;
|
|
181
|
+
const value = message[key];
|
|
182
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Extract error text from an errored assistant message, trying content text parts first then top-level error fields. */
|
|
188
|
+
function extractAssistantErrorText(message: Record<string, unknown>): string | undefined {
|
|
189
|
+
// 1. Try content text parts (model may embed error in text)
|
|
190
|
+
const contentError = extractContentErrorText(message.content);
|
|
191
|
+
if (contentError) return contentError;
|
|
192
|
+
|
|
193
|
+
// 2. Try top-level error message (provider-level error payload)
|
|
194
|
+
if (typeof message.errorMessage === "string" && message.errorMessage.trim()) {
|
|
195
|
+
return message.errorMessage;
|
|
196
|
+
}
|
|
197
|
+
if (typeof message.error === "string" && message.error.trim()) {
|
|
198
|
+
return message.error;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 3. Fallback: scan enumerable keys for any error-like string property
|
|
202
|
+
return scanMessageKeysForError(message);
|
|
203
|
+
}
|
|
@@ -53,9 +53,16 @@ export type ChildLifecycleTraceEntry =
|
|
|
53
53
|
willRetry: boolean;
|
|
54
54
|
hasResult: boolean;
|
|
55
55
|
hasError: boolean;
|
|
56
|
+
errorText?: string;
|
|
56
57
|
}
|
|
57
58
|
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number }
|
|
58
|
-
| {
|
|
59
|
+
| {
|
|
60
|
+
type: "auto_retry_end";
|
|
61
|
+
success: boolean;
|
|
62
|
+
attempt: number;
|
|
63
|
+
hasFinalError: boolean;
|
|
64
|
+
finalErrorText?: string;
|
|
65
|
+
}
|
|
59
66
|
| {
|
|
60
67
|
type: "summarization_retry_scheduled";
|
|
61
68
|
attempt: number;
|
|
@@ -161,6 +168,20 @@ export class ChildLifecycleTraceCollector {
|
|
|
161
168
|
}
|
|
162
169
|
}
|
|
163
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Extract error text from the most recent lifecycle entry that carries an error.
|
|
173
|
+
* Checks compaction_end.errorText and auto_retry_end.finalErrorText.
|
|
174
|
+
*/
|
|
175
|
+
export function extractLastLifecycleErrorText(trace: ChildLifecycleTrace): string | undefined {
|
|
176
|
+
// Walk backwards to find the most recent error
|
|
177
|
+
for (let i = trace.entries.length - 1; i >= 0; i--) {
|
|
178
|
+
const entry = trace.entries[i];
|
|
179
|
+
if (entry.type === "compaction_end" && entry.errorText) return entry.errorText;
|
|
180
|
+
if (entry.type === "auto_retry_end" && entry.finalErrorText) return entry.finalErrorText;
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
164
185
|
/** Format the complete retained Child Lifecycle Trace for parent-facing diagnostics. */
|
|
165
186
|
export function formatChildLifecycleTrace(trace: ChildLifecycleTrace): string {
|
|
166
187
|
const tail =
|
|
@@ -185,11 +206,11 @@ function formatChildLifecycleTraceEntry(entry: ChildLifecycleTraceEntry): string
|
|
|
185
206
|
case "compaction_start":
|
|
186
207
|
return `compaction_start(reason=${entry.reason})`;
|
|
187
208
|
case "compaction_end":
|
|
188
|
-
return `compaction_end(reason=${entry.reason}, aborted=${entry.aborted}, willRetry=${entry.willRetry}, hasResult=${entry.hasResult}, hasError=${entry.hasError})`;
|
|
209
|
+
return `compaction_end(reason=${entry.reason}, aborted=${entry.aborted}, willRetry=${entry.willRetry}, hasResult=${entry.hasResult}, hasError=${entry.hasError}${entry.errorText ? `, error=${JSON.stringify(entry.errorText)}` : ""})`;
|
|
189
210
|
case "auto_retry_start":
|
|
190
211
|
return `auto_retry_start(attempt=${entry.attempt}/${entry.maxAttempts}, delayMs=${entry.delayMs})`;
|
|
191
212
|
case "auto_retry_end":
|
|
192
|
-
return `auto_retry_end(success=${entry.success}, attempt=${entry.attempt}, hasFinalError=${entry.hasFinalError})`;
|
|
213
|
+
return `auto_retry_end(success=${entry.success}, attempt=${entry.attempt}, hasFinalError=${entry.hasFinalError}${entry.finalErrorText ? `, error=${JSON.stringify(entry.finalErrorText)}` : ""})`;
|
|
193
214
|
case "summarization_retry_scheduled":
|
|
194
215
|
return `summarization_retry_scheduled(attempt=${entry.attempt}/${entry.maxAttempts}, delayMs=${entry.delayMs})`;
|
|
195
216
|
case "summarization_retry_attempt_start":
|
|
@@ -269,6 +290,10 @@ function mapLifecycleEvent(event: AgentSessionEvent): ChildLifecycleTraceEntry |
|
|
|
269
290
|
willRetry: event.willRetry,
|
|
270
291
|
hasResult: event.result !== undefined,
|
|
271
292
|
hasError: event.errorMessage !== undefined,
|
|
293
|
+
errorText:
|
|
294
|
+
typeof event.errorMessage === "string" && event.errorMessage.trim()
|
|
295
|
+
? event.errorMessage
|
|
296
|
+
: undefined,
|
|
272
297
|
}
|
|
273
298
|
: undefined;
|
|
274
299
|
case "auto_retry_start":
|
|
@@ -289,6 +314,10 @@ function mapLifecycleEvent(event: AgentSessionEvent): ChildLifecycleTraceEntry |
|
|
|
289
314
|
success: event.success,
|
|
290
315
|
attempt: event.attempt,
|
|
291
316
|
hasFinalError: event.finalError !== undefined,
|
|
317
|
+
finalErrorText:
|
|
318
|
+
typeof event.finalError === "string" && event.finalError.trim()
|
|
319
|
+
? event.finalError
|
|
320
|
+
: undefined,
|
|
292
321
|
}
|
|
293
322
|
: undefined;
|
|
294
323
|
case "summarization_retry_scheduled":
|
|
@@ -119,13 +119,24 @@ function plannerPrompt(snapshot: ReviewSnapshot, context: string): string {
|
|
|
119
119
|
].join("\n");
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** Translate a resolveReviewSnapshot error to a no-target reason. */
|
|
123
|
+
function snapshotErrorReason(error: unknown): string {
|
|
124
|
+
if (error instanceof Error) return error.message;
|
|
125
|
+
return "No reviewable changes found.";
|
|
126
|
+
}
|
|
127
|
+
|
|
122
128
|
/** Resolve and store a one-shot plan, optionally asking the advisory Planner for tasks. */
|
|
123
129
|
export async function prepareReview(input: PrepareReviewInput) {
|
|
124
130
|
input.onUpdate?.({
|
|
125
131
|
content: [{ type: "text", text: "Resolving target…" }],
|
|
126
132
|
details: {},
|
|
127
133
|
});
|
|
128
|
-
|
|
134
|
+
let snapshot: Awaited<ReturnType<typeof resolveReviewSnapshot>>;
|
|
135
|
+
try {
|
|
136
|
+
snapshot = await resolveReviewSnapshot(input.cwd, input.target);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return { kind: "no-target" as const, reason: snapshotErrorReason(error) };
|
|
139
|
+
}
|
|
129
140
|
if (!snapshot) return { kind: "no-target" as const, reason: "No reviewable changes found." };
|
|
130
141
|
let plannerDraft: PlannerDraft | undefined;
|
|
131
142
|
if (input.planning === "suggest") {
|
|
@@ -258,7 +269,12 @@ export async function runReview(input: RunReviewInput) {
|
|
|
258
269
|
let provenance: ReviewBatchDetails["provenance"] = "caller-supplied";
|
|
259
270
|
|
|
260
271
|
if (input.mode === "direct") {
|
|
261
|
-
|
|
272
|
+
let resolved: Awaited<ReturnType<typeof resolveReviewSnapshot>>;
|
|
273
|
+
try {
|
|
274
|
+
resolved = await resolveReviewSnapshot(input.cwd, input.target);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return { kind: "no-target" as const, reason: snapshotErrorReason(error) };
|
|
277
|
+
}
|
|
262
278
|
if (!resolved) return { kind: "no-target" as const, reason: "No reviewable changes found." };
|
|
263
279
|
snapshot = resolved;
|
|
264
280
|
review = normalizeReviewInput(input.review);
|
package/src/tui/common.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
9
9
|
import { type Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
10
10
|
import { formatEvidenceBadge } from "@mrclrchtr/supi-core/evidence-badge";
|
|
11
11
|
import type {
|
|
12
|
+
ChildFailureDiagnostics,
|
|
12
13
|
FindingEffort,
|
|
13
14
|
FindingImpact,
|
|
14
15
|
ReviewFinding,
|
|
@@ -118,6 +119,64 @@ function renderFinding(container: Container, finding: ReviewFinding, theme: Them
|
|
|
118
119
|
container.addChild(new Spacer(1));
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/** Build a compact diagnostics section for non-completed (failed/canceled/timeout) task results. */
|
|
123
|
+
function buildNonCompletedSection(
|
|
124
|
+
container: Container,
|
|
125
|
+
result: ReviewTaskResult & { status: "failed" | "canceled" | "timeout" },
|
|
126
|
+
theme: Theme,
|
|
127
|
+
): void {
|
|
128
|
+
const diagnostics: ChildFailureDiagnostics | undefined =
|
|
129
|
+
"diagnostics" in result ? result.diagnostics : undefined;
|
|
130
|
+
if (!diagnostics) return;
|
|
131
|
+
|
|
132
|
+
container.addChild(new Spacer(1));
|
|
133
|
+
container.addChild(
|
|
134
|
+
new Text(
|
|
135
|
+
theme.fg("dim", `${diagnostics.turns} turns · ${diagnostics.toolUses} tool uses`),
|
|
136
|
+
1,
|
|
137
|
+
0,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (diagnostics.lastAssistantStopReason) {
|
|
142
|
+
container.addChild(
|
|
143
|
+
new Text(
|
|
144
|
+
theme.fg(
|
|
145
|
+
diagnostics.lastAssistantStopReason === "error" ? "error" : "dim",
|
|
146
|
+
`Last assistant stop: ${diagnostics.lastAssistantStopReason}`,
|
|
147
|
+
),
|
|
148
|
+
1,
|
|
149
|
+
0,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (diagnostics.lastAssistantErrorText) {
|
|
155
|
+
const truncated =
|
|
156
|
+
diagnostics.lastAssistantErrorText.length > 200
|
|
157
|
+
? `${diagnostics.lastAssistantErrorText.slice(0, 200)}…`
|
|
158
|
+
: diagnostics.lastAssistantErrorText;
|
|
159
|
+
container.addChild(new Text(theme.fg("error", truncated), 1, 0));
|
|
160
|
+
} else if (diagnostics.lastLifecycleErrorText) {
|
|
161
|
+
const truncated =
|
|
162
|
+
diagnostics.lastLifecycleErrorText.length > 200
|
|
163
|
+
? `${diagnostics.lastLifecycleErrorText.slice(0, 200)}…`
|
|
164
|
+
: diagnostics.lastLifecycleErrorText;
|
|
165
|
+
container.addChild(new Text(theme.fg("error", truncated), 1, 0));
|
|
166
|
+
} else if (diagnostics.lastAssistantStopReason === "error") {
|
|
167
|
+
container.addChild(
|
|
168
|
+
new Text(
|
|
169
|
+
theme.fg(
|
|
170
|
+
"dim",
|
|
171
|
+
"The model produced an error with no further details — check provider quota, auth, or configuration.",
|
|
172
|
+
),
|
|
173
|
+
1,
|
|
174
|
+
0,
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
121
180
|
/** Build an expanded-view section for a single review task result. */
|
|
122
181
|
export function buildTaskSection(
|
|
123
182
|
container: Container,
|
|
@@ -159,7 +218,10 @@ export function buildTaskSection(
|
|
|
159
218
|
container.addChild(new Text(metaParts.join(" "), 1, 0));
|
|
160
219
|
|
|
161
220
|
// Completed task — show summary and findings
|
|
162
|
-
if (result.status !== "completed")
|
|
221
|
+
if (result.status !== "completed") {
|
|
222
|
+
buildNonCompletedSection(container, result, theme);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
163
225
|
|
|
164
226
|
if (result.summary) {
|
|
165
227
|
container.addChild(new Spacer(1));
|
package/src/types.ts
CHANGED
|
@@ -109,6 +109,10 @@ export interface ChildFailureDiagnostics {
|
|
|
109
109
|
recentActivity?: string[];
|
|
110
110
|
lastAssistantStopReason?: string;
|
|
111
111
|
lastAssistantToolCalls?: string[];
|
|
112
|
+
/** Error text extracted from the last assistant message when stopReason is "error". */
|
|
113
|
+
lastAssistantErrorText?: string;
|
|
114
|
+
/** Error text extracted from the most recent lifecycle event (compaction_end, auto_retry_end). */
|
|
115
|
+
lastLifecycleErrorText?: string;
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
export type ChildFailedResult =
|