Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion apps/server/src/agents/reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export async function resolveReviewFeedbackItem(
pool: Pool,
itemId: number,
agentId: string,
resolution: "fixed" | "ignored" | "wont_fix",
resolution: "fixed" | "dismissed",
opts: { note?: string | null; resolvedBy?: string | null } = {}
): Promise<{
item: ReviewFeedbackItemRecord;
Expand Down Expand Up @@ -310,6 +310,70 @@ export async function resolveReviewFeedbackItem(
}
}

export async function reopenReviewFeedbackItem(
pool: Pool,
itemId: number,
agentId: string
): Promise<{
item: ReviewFeedbackItemRecord;
reviewId: number;
reviewStatus: string;
} | null> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const itemResult = await client.query<ReviewFeedbackItemRecord>(
`UPDATE review_feedback_items fi
SET status = 'open', resolution = NULL, resolution_note = NULL,
resolved_by = NULL, resolved_at = NULL, updated_at = NOW()
FROM reviews r
WHERE fi.id = $1 AND fi.review_id = r.id
AND (r.agent_id = $2 OR r.assigned_agent_id = $2)
RETURNING
fi.id, fi.review_id AS "reviewId", fi.file_path AS "filePath",
fi.line_start AS "lineStart", fi.line_end AS "lineEnd",
fi.diff_snapshot AS "diffSnapshot", fi.base_ref AS "baseRef",
fi.status, fi.resolution, fi.resolution_note AS "resolutionNote",
fi.resolved_by AS "resolvedBy", fi.resolved_at AS "resolvedAt",
fi.created_at AS "createdAt", fi.updated_at AS "updatedAt"`,
[itemId, agentId]
);
const item = itemResult.rows[0];
if (!item) {
await client.query("ROLLBACK");
return null;
}

const countsResult = await client.query<{
total: number;
resolved: number;
}>(
`SELECT COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status = 'resolved')::int AS resolved
FROM review_feedback_items WHERE review_id = $1`,
[item.reviewId]
);
const { total, resolved } = countsResult.rows[0]!;
const reviewStatus =
resolved === total
? "resolved"
: resolved > 0
? "partially_resolved"
: "open";
await client.query(
`UPDATE reviews SET status = $1, updated_at = NOW() WHERE id = $2`,
[reviewStatus, item.reviewId]
);
await client.query("COMMIT");
return { item, reviewId: item.reviewId, reviewStatus };
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}

export async function addThreadMessage(
pool: Pool,
itemId: number,
Expand All @@ -318,6 +382,11 @@ export async function addThreadMessage(
body: string,
authorAgentId?: string | null
): Promise<{ message: ReviewThreadMessageRecord; reviewId: number } | null> {
if (authorType === "agent" && body.length > 1_200) {
throw new Error(
"Agent review thread replies must be 1,200 characters or fewer."
);
}
const ownership = await pool.query<{ reviewId: number }>(
`SELECT fi.review_id AS "reviewId"
FROM review_feedback_items fi
Expand Down
28 changes: 28 additions & 0 deletions apps/server/src/db/migrations/0030_agent-messages.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Agent-to-agent messages: durable record of dispatch_send_message deliveries.
-- Delivery itself stays ephemeral (tmux injection); this table is for viewing.

CREATE TABLE IF NOT EXISTS agent_messages (
id uuid PRIMARY KEY,
sender_agent_id text NOT NULL,
recipient_agent_id text NOT NULL,
sender_name text NOT NULL,
recipient_name text NOT NULL,
content text NOT NULL,
delivered boolean NOT NULL DEFAULT false,
read_at timestamptz,
-- Stored even though sender/recipient repo roots are equal today (same-repo
-- send rule). Present so cross-repo messaging becomes a config flip, not a
-- migration.
sender_repo_root text,
recipient_repo_root text,
created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS agent_messages_sender_created_idx
ON agent_messages (sender_agent_id, created_at DESC);

CREATE INDEX IF NOT EXISTS agent_messages_recipient_created_idx
ON agent_messages (recipient_agent_id, created_at DESC);

CREATE INDEX IF NOT EXISTS agent_messages_recipient_unread_idx
ON agent_messages (recipient_agent_id) WHERE read_at IS NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- The review-feedback system has two terminal states only.
ALTER TABLE review_feedback_items
DROP CONSTRAINT IF EXISTS review_feedback_items_resolution_check;

UPDATE review_feedback_items
SET resolution = 'dismissed'
WHERE resolution IN ('ignored', 'wont_fix');

ALTER TABLE review_feedback_items
ADD CONSTRAINT review_feedback_items_resolution_check
CHECK (resolution IS NULL OR resolution IN ('fixed', 'dismissed'));
2 changes: 1 addition & 1 deletion apps/server/src/db/seed/reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ const REVIEWS: ReviewSeed[] = [
lineEnd: 60,
diffSnapshot: null,
status: "resolved",
resolution: "wont_fix",
resolution: "dismissed",
resolutionNote:
"Response shape matches the existing pattern used by other endpoints — changing it would be a breaking change.",
messages: [
Expand Down
48 changes: 35 additions & 13 deletions apps/server/src/routes/reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,10 @@ export async function registerReviewRoutes(
"2. Read each feedback item. For each one, decide whether to fix it, push back, or dismiss it."
);
notifLines.push(
"3. If you have a question or want to explain your approach before acting, use dispatch_review_add_message to reply on that item's thread."
"3. If you have a question or need to explain your approach, use dispatch_review_add_message to reply on that item's thread. Keep replies to 1–3 short sentences (max 1,200 characters)."
);
notifLines.push(
"4. After addressing an item (or deciding not to), call dispatch_review_resolve to mark it as fixed, ignored, or wont_fix. Include a note when dismissing so the reviewer understands why."
"4. After addressing an item (or deciding not to), call dispatch_review_resolve to mark it as fixed or dismissed. Include a brief note when dismissing so the reviewer understands why."
);
notifLines.push(
"5. Work through all items — the review status updates automatically as you resolve each one."
Expand Down Expand Up @@ -246,13 +246,14 @@ export async function registerReviewRoutes(
note?: unknown;
} | null;

const validResolutions = ["fixed", "ignored", "wont_fix"] as const;
const validResolutions = ["fixed", "dismissed"] as const;
if (
typeof body?.resolution !== "string" ||
!(validResolutions as readonly string[]).includes(body.resolution)
body?.resolution !== null &&
(typeof body?.resolution !== "string" ||
!(validResolutions as readonly string[]).includes(body.resolution))
) {
return reply.code(400).send({
error: "resolution must be one of: fixed, ignored, wont_fix",
error: "resolution must be fixed, dismissed, or null",
});
}

Expand All @@ -270,13 +271,20 @@ export async function registerReviewRoutes(
const agent = await deps.agentManager.getAgent(agentId);
if (!agent) return reply.code(404).send({ error: "Agent not found." });

const result = await reviewQueries.resolveReviewFeedbackItem(
deps.pool,
itemId,
agentId,
body.resolution as "fixed" | "ignored" | "wont_fix",
{ note }
);
const result =
body.resolution === null
? await reviewQueries.reopenReviewFeedbackItem(
deps.pool,
itemId,
agentId
)
: await reviewQueries.resolveReviewFeedbackItem(
deps.pool,
itemId,
agentId,
body.resolution as "fixed" | "dismissed",
{ note }
);
if (!result) {
return reply.code(404).send({ error: "Feedback item not found." });
}
Expand Down Expand Up @@ -347,6 +355,20 @@ export async function registerReviewRoutes(
feedbackItemId: itemId,
});

try {
await deps.sendAgentPrompt(
agentId,
[
"--- DISPATCH: Review Thread Reply ---",
`Feedback item #${itemId}: ${body.body.trim()}`,
"Reply only if useful. Keep any reply to 1–3 short sentences (max 1,200 characters).",
"--- END ---",
].join("\n")
);
} catch {
// tmux delivery is best-effort
}

return { message: result.message };
} catch (error) {
return deps.handleAgentError(reply, error);
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/server/mcp-review-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ export function createReviewHandlers(deps: CreateReviewHandlersDeps) {
async resolveReviewFeedback(
agentId: string,
itemId: number,
resolution: "fixed" | "ignored" | "wont_fix",
resolution: "fixed" | "dismissed",
opts: { note?: string | null } = {}
): Promise<{
item: {
Expand Down
16 changes: 9 additions & 7 deletions apps/server/src/shared/mcp/persona-interaction-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,24 +292,24 @@ export function registerPersonaInteractionTools(
"dispatch_review_resolve",
{
description:
"Resolve a human review feedback item. Marks the item as fixed, dismissed, or wont_fix. The parent review status is automatically recomputed (open → partially_resolved → resolved).",
"Resolve a human review feedback item. Marks the item as fixed or dismissed. The parent review status is automatically recomputed (open → partially_resolved → resolved).",
inputSchema: {
itemId: z
.number()
.int()
.positive()
.describe("The ID of the review feedback item to resolve."),
resolution: z
.enum(["fixed", "ignored", "wont_fix"])
.enum(["fixed", "dismissed"])
.describe(
"Resolution type: 'fixed' if addressed, 'ignored' if not applicable, 'wont_fix' if acknowledged but intentionally left."
"Resolution type: 'fixed' if addressed, 'dismissed' if closing without a change."
),
note: z
.string()
.max(10_000)
.optional()
.describe(
"Optional note explaining the resolution. Encouraged for 'dismissed' and 'wont_fix'."
"Optional note explaining the resolution. Encouraged when dismissed."
),
},
},
Expand Down Expand Up @@ -347,7 +347,7 @@ export function registerPersonaInteractionTools(
"dispatch_review_add_message",
{
description:
"Add a message to a review feedback item's thread. Use this to reply to a human review comment — for example, to ask a clarifying question or explain your approach before resolving.",
"Add a concise message to a review feedback item's thread. Use this to ask a clarifying question or explain your approach before resolving. Keep it to 1–3 short sentences (maximum 1,200 characters).",
inputSchema: {
itemId: z
.number()
Expand All @@ -359,8 +359,10 @@ export function registerPersonaInteractionTools(
body: z
.string()
.min(1)
.max(50_000)
.describe("The message body (plain text or markdown)."),
.max(1_200)
.describe(
"A brief plain-text or Markdown reply (1–3 short sentences)."
),
},
},
async (args) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/shared/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ export type McpRequestContext = {
resolveReviewFeedback?: (
agentId: string,
itemId: number,
resolution: "fixed" | "ignored" | "wont_fix",
resolution: "fixed" | "dismissed",
opts?: { note?: string | null }
) => Promise<{
item: { id: number; reviewId: number; status: string; resolution: string };
Expand Down
45 changes: 35 additions & 10 deletions apps/server/test/review-feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,35 +96,32 @@ describe("PATCH /api/v1/agents/:id/reviews/items/:itemId", () => {
expect(result.item.resolution).toBe("fixed");
});

it("resolves a feedback item as ignored with a note", async () => {
it("resolves a feedback item as dismissed with a note", async () => {
const review = await createReview(AGENT_ID, [{ comment: "Do this" }]);
const itemId = review.items[0].id;

const response = await ctx.app.inject({
method: "PATCH",
url: `/api/v1/agents/${AGENT_ID}/reviews/items/${itemId}`,
headers: { cookie: sessionCookie, "content-type": "application/json" },
payload: { resolution: "ignored", note: "Not applicable" },
payload: { resolution: "dismissed", note: "Not applicable" },
});

expect(response.statusCode).toBe(200);
expect(response.json().item.resolution).toBe("ignored");
expect(response.json().item.resolution).toBe("dismissed");
expect(response.json().item.resolutionNote).toBe("Not applicable");
});

it("resolves a feedback item as wont_fix", async () => {
it("rejects unsupported review resolutions", async () => {
const review = await createReview(AGENT_ID, [{ comment: "Refactor" }]);
const itemId = review.items[0].id;

const response = await ctx.app.inject({
method: "PATCH",
url: `/api/v1/agents/${AGENT_ID}/reviews/items/${itemId}`,
url: `/api/v1/agents/${AGENT_ID}/reviews/items/${review.items[0].id}`,
headers: { cookie: sessionCookie, "content-type": "application/json" },
payload: { resolution: "wont_fix", note: "By design" },
payload: { resolution: "wont_fix" },
});

expect(response.statusCode).toBe(200);
expect(response.json().item.resolution).toBe("wont_fix");
expect(response.statusCode).toBe(400);
});

it("updates review status to resolved when all items are resolved", async () => {
Expand Down Expand Up @@ -171,6 +168,34 @@ describe("PATCH /api/v1/agents/:id/reviews/items/:itemId", () => {
expect(reviewResponse.json().review.status).toBe("partially_resolved");
});

it("reopens a resolved feedback item", async () => {
const review = await createReview(AGENT_ID, [{ comment: "Bug 1" }]);
const itemId = review.items[0].id;

await ctx.app.inject({
method: "PATCH",
url: `/api/v1/agents/${AGENT_ID}/reviews/items/${itemId}`,
headers: { cookie: sessionCookie, "content-type": "application/json" },
payload: { resolution: "fixed" },
});
const response = await ctx.app.inject({
method: "PATCH",
url: `/api/v1/agents/${AGENT_ID}/reviews/items/${itemId}`,
headers: { cookie: sessionCookie, "content-type": "application/json" },
payload: { resolution: null },
});

expect(response.statusCode).toBe(200);
expect(response.json().item.status).toBe("open");
expect(response.json().item.resolution).toBeNull();
const reviewResponse = await ctx.app.inject({
method: "GET",
url: `/api/v1/agents/${AGENT_ID}/reviews/${review.id}`,
headers: { cookie: sessionCookie },
});
expect(reviewResponse.json().review.status).toBe("open");
});

it("returns 404 for item belonging to different agent", async () => {
const review = await createReview(AGENT_ID, [{ comment: "Private" }]);
const itemId = review.items[0].id;
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/app/agents-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -368,16 +368,20 @@ export function AgentsView({
}, []);

const handleNavigateToFile = useCallback(
(filePath: string, lineStart: number | null) => {
(filePath: string, lineStart: number | null, feedbackItemId?: number) => {
if (!focusedAgentId) return;
const params = new URLSearchParams();
params.set("file", filePath);
if (lineStart != null) params.set("line", String(lineStart));
if (feedbackItemId != null) {
params.set("feedback", String(feedbackItemId));
}
navTo(`/agents/${focusedAgentId}/changes?${params.toString()}`, {
replace: true,
});
if (isMobile) setMobileMediaOpen(false);
},
[focusedAgentId, navTo]
[focusedAgentId, isMobile, navTo, setMobileMediaOpen]
);

const handleReviewSubmitted = useCallback(
Expand Down
Loading
Loading