Review every new snapshot with an agent from the other vendor
This commit is contained in:
+70
-3
@@ -513,6 +513,25 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{taskId}/reviews": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
taskId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description The task's reviews, one per snapshot, oldest first. */
|
||||
get: operations["listReviews"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{taskId}/snapshots/{n}/fork": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -962,6 +981,26 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
/** @description An agent session on the vendor the task's agent doesn't use, reviewing one snapshot's change. */
|
||||
Review: {
|
||||
id: string;
|
||||
task_id: string;
|
||||
snapshot_n: number;
|
||||
/** @description The reviewer's session. */
|
||||
agent_session_id: string;
|
||||
vendor: string;
|
||||
model: string;
|
||||
/** @enum {string} */
|
||||
state: "running" | "done" | "failed";
|
||||
/** @description done: the reviewer's submit_review arguments: summary (Markdown) and findings, each with severity, category, title, detail, path and line. */
|
||||
result?: Record<string, never>;
|
||||
/** @description failed: why. */
|
||||
error: string;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
/** Format: date-time */
|
||||
finished_at?: string;
|
||||
};
|
||||
AgentSession: {
|
||||
id: string;
|
||||
/** @description Empty for a lead session. */
|
||||
@@ -979,8 +1018,11 @@ export interface components {
|
||||
vendor_session_id: string;
|
||||
/** @enum {string} */
|
||||
state: "idle" | "running" | "completed";
|
||||
/** @enum {string} */
|
||||
role: "implementer" | "lead";
|
||||
/**
|
||||
* @description reviewer: a review's session (see Review); a task's sessions list leaves these out.
|
||||
* @enum {string}
|
||||
*/
|
||||
role: "implementer" | "lead" | "reviewer";
|
||||
created_by: string;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
@@ -1044,6 +1086,8 @@ export interface components {
|
||||
/** @description question: context (Markdown with the choices inline as `- ( ) text` / `- (*) text` pick-one or `- [ ] text` / `- [x] text` pick-any list items), question, why_blocked, options (each choice's text, in order), multiple (true for pick-any), recommended (the marked choices' text), attachments. report: text (the final message of a turn that ended normally). session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error. */
|
||||
payload: Record<string, never>;
|
||||
state: components["schemas"]["InboxState"];
|
||||
/** @description question: the review of the first snapshot the task took after the question was asked (the snapshot at the end of the turn that asked it), once that snapshot exists; empty for other items. */
|
||||
reviews: components["schemas"]["Review"][];
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
resolved_by?: string;
|
||||
@@ -1106,7 +1150,7 @@ export interface components {
|
||||
focused: boolean;
|
||||
};
|
||||
/** @enum {string} */
|
||||
TaskTab: "transcript" | "changes" | "files" | "terminal" | "preview" | "inbox" | "project";
|
||||
TaskTab: "transcript" | "changes" | "files" | "terminal" | "preview" | "inbox" | "reviews" | "project";
|
||||
TerminalSize: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -2016,6 +2060,29 @@ export interface operations {
|
||||
404: components["responses"]["Error"];
|
||||
};
|
||||
};
|
||||
listReviews: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
taskId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The reviews. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Review"][];
|
||||
};
|
||||
};
|
||||
404: components["responses"]["Error"];
|
||||
};
|
||||
};
|
||||
forkSnapshot: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mountChanges } from "./changes.js";
|
||||
import { ago, errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { image, inlineMarkdown, markdown } from "./markdown.js";
|
||||
import { diagram } from "./mermaid.js";
|
||||
import { reviewCard } from "./reviews.js";
|
||||
import { focusTab, onEach, state } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
|
||||
@@ -103,6 +104,7 @@ function card(item: InboxItem): HTMLElement {
|
||||
h("time", { dateTime: item.created_at, title: item.created_at }, ago(item.created_at)),
|
||||
),
|
||||
body,
|
||||
item.reviews.length > 0 ? h("section", { class: "card-reviews" }, ...item.reviews.map((r) => reviewCard(r, item.task_id))) : null,
|
||||
thread(item),
|
||||
actions,
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { openSwitcher } from "./navbar.js";
|
||||
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
|
||||
import { mountPreview } from "./preview.js";
|
||||
import { mountProject } from "./project.js";
|
||||
import { mountReviews } from "./reviews.js";
|
||||
import { mountTranscript } from "./task.js";
|
||||
import { mountTerminal } from "./terminal.js";
|
||||
|
||||
@@ -18,6 +19,7 @@ const views: Record<TaskTab, { label: string; mount: (root: HTMLElement) => () =
|
||||
terminal: { label: "Terminal", mount: mountTerminal },
|
||||
preview: { label: "Preview", mount: mountPreview },
|
||||
inbox: { label: "Inbox", mount: mountInbox },
|
||||
reviews: { label: "Reviews", mount: mountReviews },
|
||||
project: { label: "Project", mount: mountProject },
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { get, type ByreEvent, type Schemas } from "./api.js";
|
||||
import { ago, errorText, h, replace } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { onEach, state } from "./state.js";
|
||||
|
||||
type Review = Schemas["Review"];
|
||||
|
||||
const severities = ["critical", "high", "medium", "low", "info"];
|
||||
|
||||
export function isReviewEvent(ev: ByreEvent): boolean {
|
||||
return ev.kind.startsWith("review.");
|
||||
}
|
||||
|
||||
export function reviewCounts(r: Review): string {
|
||||
const res = (r.result ?? {}) as Record<string, unknown>;
|
||||
const findings = Array.isArray(res["findings"]) ? (res["findings"] as Record<string, unknown>[]) : [];
|
||||
const parts = severities.map((s) => [s, findings.filter((f) => f["severity"] === s).length] as const).filter(([, n]) => n > 0);
|
||||
return parts.length > 0 ? parts.map(([s, n]) => `${n} ${s}`).join(", ") : "no findings";
|
||||
}
|
||||
|
||||
function stateText(r: Review): string {
|
||||
switch (r.state) {
|
||||
case "running":
|
||||
return `reviewing on ${r.vendor} (${r.model})…`;
|
||||
case "done":
|
||||
return reviewCounts(r);
|
||||
case "failed":
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
export function reviewCard(r: Review, taskId: string | undefined): HTMLElement {
|
||||
return h(
|
||||
"article",
|
||||
{ class: `review ${r.state}` },
|
||||
h(
|
||||
"header",
|
||||
{},
|
||||
h("span", { class: "review-snapshot" }, `snapshot #${r.snapshot_n}`),
|
||||
h("span", { class: `review-state ${r.state}` }, stateText(r)),
|
||||
h("time", { dateTime: r.created_at, title: r.created_at }, ago(r.created_at)),
|
||||
),
|
||||
r.state === "failed" ? h("p", { class: "error-text" }, r.error) : null,
|
||||
r.state === "done" ? reviewResult(r, taskId) : null,
|
||||
);
|
||||
}
|
||||
|
||||
function reviewResult(r: Review, taskId: string | undefined): HTMLElement {
|
||||
const res = (r.result ?? {}) as Record<string, unknown>;
|
||||
const findings = Array.isArray(res["findings"]) ? (res["findings"] as Record<string, unknown>[]) : [];
|
||||
const md = { taskId };
|
||||
return h(
|
||||
"div",
|
||||
{ class: "review-result" },
|
||||
markdown(String(res["summary"] ?? ""), "md review-summary", md),
|
||||
findings.length > 0
|
||||
? h(
|
||||
"ol",
|
||||
{ class: "findings" },
|
||||
...findings.map((f) => {
|
||||
const severity = String(f["severity"] ?? "");
|
||||
const where = typeof f["path"] === "string" && f["path"] ? `${f["path"]}${typeof f["line"] === "number" && f["line"] > 0 ? `:${f["line"]}` : ""}` : "";
|
||||
return h(
|
||||
"li",
|
||||
{ class: `finding ${severity}` },
|
||||
h(
|
||||
"div",
|
||||
{ class: "finding-head" },
|
||||
h("span", { class: `severity ${severity}` }, severity),
|
||||
typeof f["category"] === "string" && f["category"] ? h("span", { class: "finding-category" }, f["category"]) : null,
|
||||
h("span", { class: "finding-title" }, String(f["title"] ?? "")),
|
||||
where ? h("code", { class: "finding-where" }, where) : null,
|
||||
),
|
||||
markdown(String(f["detail"] ?? ""), "md finding-detail", md),
|
||||
);
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
export function mountReviews(root: HTMLElement): () => void {
|
||||
const list = h("div", { class: "review-list" });
|
||||
let reviews: Review[] | undefined;
|
||||
let error = "";
|
||||
let target = 0;
|
||||
let disposed = false;
|
||||
|
||||
const load = async () => {
|
||||
const taskId = state.selectedTaskId;
|
||||
if (!taskId) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const started = ++target;
|
||||
try {
|
||||
const fresh = await get("/api/tasks/{taskId}/reviews", { path: { taskId } });
|
||||
if (started === target) {
|
||||
reviews = fresh;
|
||||
error = "";
|
||||
}
|
||||
} catch (err) {
|
||||
if (started === target) {
|
||||
reviews = undefined;
|
||||
error = errorText(err);
|
||||
}
|
||||
}
|
||||
if (!disposed && started === target) {
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const taskId = state.selectedTaskId;
|
||||
if (!taskId) {
|
||||
replace(list, h("div", { class: "empty" }, "No task selected."));
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
replace(list, h("div", { class: "empty error-text" }, error));
|
||||
return;
|
||||
}
|
||||
if (!reviews) {
|
||||
replace(list, h("div", { class: "empty" }, "Loading…"));
|
||||
return;
|
||||
}
|
||||
if (reviews.length === 0) {
|
||||
replace(list, h("div", { class: "empty" }, "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor."));
|
||||
return;
|
||||
}
|
||||
replace(list, ...[...reviews].reverse().map((r) => reviewCard(r, taskId)));
|
||||
};
|
||||
|
||||
const offs = [
|
||||
onEach(["selection"], () => {
|
||||
target++;
|
||||
reviews = undefined;
|
||||
error = "";
|
||||
render();
|
||||
void load();
|
||||
}),
|
||||
onEach(["transcript"], (fresh) => {
|
||||
if ((fresh as ByreEvent[]).some(isReviewEvent)) {
|
||||
void load();
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
replace(root, h("div", { class: "reviews-view" }, list));
|
||||
render();
|
||||
void load();
|
||||
return () => {
|
||||
disposed = true;
|
||||
for (const off of offs) {
|
||||
off();
|
||||
}
|
||||
};
|
||||
}
|
||||
+7
-1
@@ -17,7 +17,7 @@ export type Topic =
|
||||
| "lead.transcript"
|
||||
| "lead.stream";
|
||||
|
||||
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "preview", "inbox", "project"];
|
||||
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "preview", "inbox", "reviews", "project"];
|
||||
|
||||
export const state = {
|
||||
me: undefined as User | undefined,
|
||||
@@ -306,6 +306,12 @@ export function handleEvent(ev: ByreEvent): void {
|
||||
case "inbox.updated":
|
||||
void loadInbox();
|
||||
break;
|
||||
case "review.started":
|
||||
case "review.finished":
|
||||
if (state.inbox.some((i) => i.task_id === ev.task_id && i.kind === "question")) {
|
||||
void loadInbox();
|
||||
}
|
||||
break;
|
||||
case "focus.updated":
|
||||
if (ev.actor !== state.me?.login) {
|
||||
applyFocus(ev.payload as unknown as Focus);
|
||||
|
||||
+22
-2
@@ -1,12 +1,15 @@
|
||||
import { get, post, type AgentSession, type ByreEvent, type Participant, type Snapshot } from "./api.js";
|
||||
import { get, post, type AgentSession, type ByreEvent, type Participant, type Schemas, type Snapshot } from "./api.js";
|
||||
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { previewURL } from "./preview.js";
|
||||
import { reviewCounts } from "./reviews.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, state, type Topic } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
|
||||
export type Usage = { used: number; window: number; model: string };
|
||||
|
||||
type Review = Schemas["Review"];
|
||||
|
||||
export type ChatSource = {
|
||||
key: () => string | undefined;
|
||||
events: () => ByreEvent[];
|
||||
@@ -64,6 +67,7 @@ type Subagent = {
|
||||
type Ctx = {
|
||||
tools: Map<string, HTMLElement>;
|
||||
subagents: Map<string, Subagent>;
|
||||
reviewers: Set<string>;
|
||||
run: Run | undefined;
|
||||
root: () => string;
|
||||
taskId: string | undefined;
|
||||
@@ -245,6 +249,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
const newCtx = (): Ctx => ({
|
||||
tools: new Map(),
|
||||
subagents: new Map(),
|
||||
reviewers: new Set(),
|
||||
run: undefined,
|
||||
lastMessage: undefined,
|
||||
held: undefined,
|
||||
@@ -398,7 +403,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
stream = undefined;
|
||||
};
|
||||
const appendDelta = (ev: ByreEvent) => {
|
||||
if (ctx.handoff) {
|
||||
if (ctx.handoff || (ev.agent_session_id && ctx.reviewers.has(ev.agent_session_id))) {
|
||||
return;
|
||||
}
|
||||
if (!stream) {
|
||||
@@ -447,6 +452,13 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
for (const ev of events) {
|
||||
const p = ev.payload as Record<string, unknown>;
|
||||
const parent = parentOf(p);
|
||||
if (ev.kind === "session.created" && p["role"] === "reviewer" && ev.agent_session_id) {
|
||||
ctx.reviewers.add(ev.agent_session_id);
|
||||
continue;
|
||||
}
|
||||
if (ev.agent_session_id && ctx.reviewers.has(ev.agent_session_id)) {
|
||||
continue;
|
||||
}
|
||||
if (ev.kind === "agent.vendor") {
|
||||
claudeTaskStatus(p, ctx);
|
||||
continue;
|
||||
@@ -1038,6 +1050,14 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
|
||||
case "task.merge_title":
|
||||
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
|
||||
|
||||
case "review.finished": {
|
||||
const r = p as unknown as Review;
|
||||
const open = h("button", { type: "button", title: "Show the task's reviews" }, "reviews");
|
||||
open.onclick = () => focusTab("reviews");
|
||||
const outcome = r.state === "failed" ? `failed: ${r.error}` : reviewCounts(r);
|
||||
return h("div", { class: `note${r.state === "failed" ? " error" : ""}` }, `review of snapshot #${r.snapshot_n}: ${outcome} `, when, " ", open);
|
||||
}
|
||||
|
||||
case "task.merge_queued":
|
||||
return h("div", { class: "note" }, `${ev.actor} asked to merge; held until the merge in progress in this project is done `, when);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user