fix lgtm review gating and evidence display

This commit is contained in:
wassname
2026-06-07 18:55:05 +08:00
parent dbe887f0c2
commit 023a2c2263
11 changed files with 394 additions and 92 deletions
+40 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getDisplayStatus, getReviewBadges } from "../src/review-badges.js";
import { getDisplayStatus, getGateStatus, getReviewBadges } from "../src/review-badges.js";
import type { Task } from "../src/types.js";
function makeTask(overrides: Partial<Task> = {}): Task {
@@ -60,6 +60,45 @@ describe("getReviewBadges", () => {
});
});
describe("getGateStatus", () => {
it("reports ready when human sign-off is open", () => {
expect(getGateStatus(makeTask({
pending_approval: true,
metadata: { lgtm_evidence: "ok" },
}))).toBe("ready for human sign-off via /lgtm 1");
});
it("reports reviewer failure separately from rejected evidence", () => {
expect(getGateStatus(makeTask({
metadata: {
lgtm_evidence: "ok",
robot_review_last_error: "Unexpected token 'a'",
},
}))).toContain("automatic robot review failed");
});
it("reports rejected robot review when latest review does not accept", () => {
expect(getGateStatus(makeTask({
metadata: {
lgtm_evidence: "ok",
robot_reviews: [{
iteration: 1,
reviewer: "opencode",
scope: "task evidence",
observations: ["Observed missing output"],
blind_spots: "none",
accepted: false,
evidence_complete: false,
evidence_convincing: false,
missing_evidence: ["literal output"],
submitted_at: "2026-04-17T00:00:00.000Z",
mode: "manual",
}],
},
}))).toBe("blocked: latest robot review rejected the evidence");
});
});
describe("getDisplayStatus", () => {
it("returns pending for fresh tasks", () => {
expect(getDisplayStatus(makeTask())).toBe("pending");
+17
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_ROBOT_REVIEW_TIMEOUT_MS,
extractFinalAssistantTextFromPiJsonl,
extractRobotReviewJson,
getPiInvocation,
getRobotReviewTimeoutMs,
runRobotReviewCommand,
@@ -27,6 +28,22 @@ describe("robot review runner helpers", () => {
expect(extractFinalAssistantTextFromPiJsonl(output)).toContain("ROBOT_REVIEW_JSON_START");
});
it("parses noisy JSON wrapped in review markers", () => {
const output = [
"ROBOT_REVIEW_JSON_START",
"and here is the JSON you asked for:",
"```json",
'{"accepted":true,"observations":["ok"]}',
"```",
"ROBOT_REVIEW_JSON_END",
].join("\n");
expect(extractRobotReviewJson(output)).toEqual({ accepted: true, observations: ["ok"] });
});
it("includes raw output context on parse failure", () => {
expect(() => extractRobotReviewJson("ROBOT_REVIEW_JSON_START and nope ROBOT_REVIEW_JSON_END")).toThrow(/Raw output:/);
});
it("uses configured timeout or falls back to default", () => {
expect(getRobotReviewTimeoutMs({ PI_LGTM_ROBOT_REVIEW_TIMEOUT_MS: "2500" } as NodeJS.ProcessEnv)).toBe(2500);
expect(getRobotReviewTimeoutMs({ PI_LGTM_ROBOT_REVIEW_TIMEOUT_MS: "bad" } as NodeJS.ProcessEnv)).toBe(DEFAULT_ROBOT_REVIEW_TIMEOUT_MS);
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { appendRobotReviewMetadata, getLatestRobotReview, getRobotReviews } from "../src/robot-review.js";
import { appendRobotReviewMetadata, getLatestRobotReview, getRobotReviews, shouldOpenHumanSignoffGate } from "../src/robot-review.js";
import type { Task } from "../src/types.js";
function makeTask(overrides: Partial<Task> = {}): Task {
@@ -21,6 +21,12 @@ function makeTask(overrides: Partial<Task> = {}): Task {
}
describe("robot review helpers", () => {
it("reopens the human gate when accepted review exists for stored evidence", () => {
expect(shouldOpenHumanSignoffGate(makeTask({ metadata: { lgtm_evidence: "literal output" } }), true)).toBe(true);
expect(shouldOpenHumanSignoffGate(makeTask({ metadata: { lgtm_evidence: "literal output" } }), false)).toBe(false);
expect(shouldOpenHumanSignoffGate(makeTask(), true)).toBe(false);
});
it("reads legacy single-review metadata", () => {
const task = makeTask({
metadata: {
+25 -26
View File
@@ -34,7 +34,7 @@ describe("TaskStore (in-memory)", () => {
it("creates tasks with optional fields", () => {
const t = store.create("Task", "Desc", "done criterion", "Running task", { key: "value" });
expect(t.activeForm).toBe("Running task");
expect(t.progress_label).toBe("Running task");
expect(t.metadata).toEqual({ key: "value" });
});
@@ -72,16 +72,16 @@ describe("TaskStore (in-memory)", () => {
const { changedFields } = store.update("1", {
subject: "Updated subject",
description: "Updated desc",
owner: "agent-1",
metadata: { owner: "agent-1" },
});
expect(changedFields).toContain("subject");
expect(changedFields).toContain("description");
expect(changedFields).toContain("owner");
expect(changedFields).toContain("metadata");
const task = store.get("1")!;
expect(task.subject).toBe("Updated subject");
expect(task.owner).toBe("agent-1");
expect(task.metadata.owner).toBe("agent-1");
});
it("deletes a task with status: deleted", () => {
@@ -114,7 +114,7 @@ describe("TaskStore (in-memory)", () => {
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
store.update("1", { add_blocks: ["2"] });
const t1 = store.get("1")!;
const t2 = store.get("2")!;
@@ -126,7 +126,7 @@ describe("TaskStore (in-memory)", () => {
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("2", { addBlockedBy: ["1"] });
store.update("2", { add_blocked_by: ["1"] });
const t1 = store.get("1")!;
const t2 = store.get("2")!;
@@ -138,8 +138,8 @@ describe("TaskStore (in-memory)", () => {
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
store.update("1", { addBlocks: ["2"] }); // duplicate
store.update("1", { add_blocks: ["2"] });
store.update("1", { add_blocks: ["2"] }); // duplicate
const t1 = store.get("1")!;
expect(t1.blocks.filter(id => id === "2")).toHaveLength(1);
@@ -148,7 +148,7 @@ describe("TaskStore (in-memory)", () => {
it("cleans up dependency edges on deletion", () => {
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
store.update("1", { add_blocks: ["2"] });
store.update("1", { status: "deleted" });
@@ -230,8 +230,8 @@ describe("TaskStore (in-memory)", () => {
it("allows circular dependencies with warning", () => {
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
const { warnings } = store.update("2", { addBlocks: ["1"] });
store.update("1", { add_blocks: ["2"] });
const { warnings } = store.update("2", { add_blocks: ["1"] });
expect(store.get("1")!.blocks).toContain("2");
expect(store.get("2")!.blocks).toContain("1");
@@ -240,14 +240,14 @@ describe("TaskStore (in-memory)", () => {
it("allows self-dependency with warning", () => {
store.create("Self", "Desc", "done");
const { warnings } = store.update("1", { addBlocks: ["1"] });
const { warnings } = store.update("1", { add_blocks: ["1"] });
expect(store.get("1")!.blocks).toContain("1");
expect(warnings).toContain("#1 blocks itself");
});
it("stores dangling edge IDs with warning", () => {
store.create("Real", "Desc", "done");
const { warnings } = store.update("1", { addBlocks: ["9999"] });
const { warnings } = store.update("1", { add_blocks: ["9999"] });
expect(store.get("1")!.blocks).toContain("9999");
expect(warnings).toContain("#9999 does not exist");
});
@@ -255,7 +255,7 @@ describe("TaskStore (in-memory)", () => {
it("returns no warnings for valid dependencies", () => {
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
const { warnings } = store.update("1", { addBlocks: ["2"] });
const { warnings } = store.update("1", { add_blocks: ["2"] });
expect(warnings).toEqual([]);
});
@@ -264,11 +264,11 @@ describe("TaskStore (in-memory)", () => {
expect(t.subject).toBe(" ");
});
it("updates activeForm field", () => {
it("updates progress_label field", () => {
store.create("Test", "Desc", "done");
const { changedFields } = store.update("1", { activeForm: "Running tests" });
expect(changedFields).toContain("activeForm");
expect(store.get("1")!.activeForm).toBe("Running tests");
const { changedFields } = store.update("1", { progress_label: "Running tests" });
expect(changedFields).toContain("progress_label");
expect(store.get("1")!.progress_label).toBe("Running tests");
});
it("updates description field", () => {
@@ -295,9 +295,8 @@ describe("TaskStore (in-memory)", () => {
it("clearCompleted cleans up dependency edges", () => {
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
createAndApprove(store, "dummy"); // need task 1 to have pending_approval
// Actually set pending_approval on task 1
store.update("1", { add_blocks: ["2"] });
// Set pending_approval on task 1 so complete() works via /lgtm path
store.update("1", { pending_approval: true });
store.complete("1");
@@ -312,7 +311,7 @@ describe("TaskStore (in-memory)", () => {
store.create("B1", "Desc", "done");
store.create("B2", "Desc", "done");
store.update("1", { addBlocks: ["2", "3"] });
store.update("1", { add_blocks: ["2", "3"] });
expect(store.get("1")!.blocks).toEqual(["2", "3"]);
expect(store.get("2")!.blockedBy).toContain("1");
@@ -321,14 +320,14 @@ describe("TaskStore (in-memory)", () => {
it("addBlockedBy warns on self-dependency", () => {
store.create("Self", "Desc", "done");
const { warnings } = store.update("1", { addBlockedBy: ["1"] });
const { warnings } = store.update("1", { add_blocked_by: ["1"] });
expect(store.get("1")!.blockedBy).toContain("1");
expect(warnings).toContain("#1 blocks itself");
});
it("addBlockedBy warns on dangling ref", () => {
store.create("Real", "Desc", "done");
const { warnings } = store.update("1", { addBlockedBy: ["9999"] });
const { warnings } = store.update("1", { add_blocked_by: ["9999"] });
expect(store.get("1")!.blockedBy).toContain("9999");
expect(warnings).toContain("#9999 does not exist");
});
@@ -336,8 +335,8 @@ describe("TaskStore (in-memory)", () => {
it("addBlockedBy warns on cycle", () => {
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
const { warnings } = store.update("1", { addBlockedBy: ["2"] });
store.update("1", { add_blocks: ["2"] });
const { warnings } = store.update("1", { add_blocked_by: ["2"] });
expect(warnings).toContain("cycle: #1 and #2 block each other");
});
+10 -8
View File
@@ -73,7 +73,7 @@ describe("TaskWidget", () => {
widget.update();
const lines = renderWidget(ui.state);
expect(lines).toHaveLength(2); // header + 1 task
expect(lines).toHaveLength(3); // header + 1 task + done_criterion
expect(lines[0]).toContain("1 tasks");
expect(lines[0]).toContain("1 open");
expect(lines[1]).toContain("◻");
@@ -129,18 +129,20 @@ describe("TaskWidget", () => {
it("shows blocked-by info for pending tasks", () => {
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("2", { addBlockedBy: ["1"] });
store.update("2", { add_blocked_by: ["1"] });
widget.update();
const lines = renderWidget(ui.state);
const blockedLine = lines.find(l => l.includes("Blocked"));
// blocked-by suffix is only added via dim theme helper, which in mock is identity
// So we should see the raw text. Check for the relevant subject line having blocked-by info
expect(blockedLine).toContain("blocked by #1");
});
it("hides completed blockers in blocked-by suffix", () => {
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("2", { addBlockedBy: ["1"] });
store.update("2", { add_blocked_by: ["1"] });
store.update("1", { pending_approval: true });
store.complete("1");
widget.update();
@@ -178,14 +180,14 @@ describe("TaskWidget", () => {
it("limits visible tasks to MAX_VISIBLE_TASKS", () => {
for (let i = 0; i < 15; i++) {
store.create(`Task ${i + 1}`, "Desc");
store.create(`Task ${i + 1}`, "Desc", "done");
}
widget.update();
const lines = renderWidget(ui.state);
// header + 10 tasks + "… and 5 more"
// header + 5 visible tasks (each has 2 lines: task + done_criterion) + "...and 10 more"
expect(lines).toHaveLength(12);
expect(lines[11]).toContain("5 more");
expect(lines[11]).toContain("10 more");
});
it("tracks token usage for active tasks", () => {
@@ -244,7 +246,7 @@ describe("TaskWidget", () => {
const lines = renderWidget(ui.state);
expect(lines[1]).toContain("Processing A…");
expect(lines[2]).toContain("Processing B…");
expect(lines[3]).toContain("Processing B…");
});
it("distributes token usage across all active tasks", () => {
@@ -260,7 +262,7 @@ describe("TaskWidget", () => {
const lines = renderWidget(ui.state);
// Both tasks should have the same token counts
expect(lines[1]).toContain("↑ 100");
expect(lines[2]).toContain("↑ 100");
expect(lines[3]).toContain("↑ 100");
});
it("dispose clears widget and timer", () => {