diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0eaf5a7c16a..c8702944b38 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -142,6 +142,7 @@ export const make = Effect.gen(function* () { connectionProbe: true, threadSettlement: true, threadSnooze: true, + reviewSweep: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 256f10fb3b8..83a1c868b37 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -365,6 +365,7 @@ function createTextGeneration( }), ), ), + generateThreadReview: () => Effect.die("generateThreadReview is not used by GitManager tests"), }; } diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 839eb73b2bb..6d64c383dde 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -3,17 +3,85 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import { + ThreadId, + type OrchestrationThread, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as ReviewService from "./ReviewService.ts"; +const GhPrViewFixture = Schema.Struct({ + number: Schema.Int, + url: Schema.String, + state: Schema.String, + reviewDecision: Schema.optionalKey(Schema.NullOr(Schema.String)), + mergeable: Schema.optionalKey(Schema.NullOr(Schema.String)), + statusCheckRollup: Schema.optionalKey(Schema.Array(Schema.Struct({ conclusion: Schema.String }))), + comments: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + author: Schema.Struct({ login: Schema.String }), + body: Schema.String, + createdAt: Schema.String, + }), + ), + ), +}); +const encodeGhPrViewFixture = Schema.encodeUnknownSync(Schema.fromJsonString(GhPrViewFixture)); + +function ghPrViewJson(fixture: typeof GhPrViewFixture.Type): string { + return encodeGhPrViewFixture(fixture); +} + +function makeShellFromThread( + thread: OrchestrationThread, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, + session: thread.session, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + function makeLayer(input: { readonly workspaceRoot: string; readonly baseDir: string; readonly detectCalls?: Array<{ readonly cwd: string }>; + readonly thread?: OrchestrationThread; + readonly shellOverrides?: Partial; + readonly generateThreadReview?: TextGeneration.TextGeneration["Service"]["generateThreadReview"]; + readonly reviewCalls?: Array; + readonly ghExecute?: GitHubCli.GitHubCli["Service"]["execute"]; }) { return ReviewService.layer.pipe( Layer.provide( @@ -28,11 +96,102 @@ function makeLayer(input: { }), ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getThreadDetailById: (threadId) => + Effect.succeed( + input.thread && input.thread.id === threadId + ? Option.some(input.thread) + : Option.none(), + ), + getThreadShellById: (threadId) => + Effect.succeed( + input.thread && input.thread.id === threadId + ? Option.some(makeShellFromThread(input.thread, input.shellOverrides)) + : Option.none(), + ), + getProjectShellById: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(TextGeneration.TextGeneration)({ + generateThreadReview: (reviewInput) => { + input.reviewCalls?.push(reviewInput); + return ( + input.generateThreadReview?.(reviewInput) ?? + Effect.succeed({ + summary: "Did the thing.", + nextStep: "Settle this thread.", + suggestedTitle: null, + recommendSettle: true, + settleReason: "Work concluded.", + }) + ); + }, + }), + ), + Layer.provide( + Layer.mock(GitHubCli.GitHubCli)({ + // Threads in these tests have no PR; investigation must degrade to + // "no context" without touching gh. + execute: input.ghExecute ?? (() => Effect.die("gh not stubbed in this test")), + }), + ), + Layer.provide(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)), Layer.provideMerge(NodeServices.layer), ); } +function makeThread(overrides: Partial = {}): OrchestrationThread { + return { + id: ThreadId.make("thread_review_1"), + projectId: "project_1" as OrchestrationThread["projectId"], + title: "New thread", + modelSelection: { + instanceId: "codex" as OrchestrationThread["modelSelection"]["instanceId"], + model: "gpt-5", + options: [], + }, + runtimeMode: "local", + interactionMode: "chat", + branch: null, + // Tests mock getProjectShellById to None, so cwd resolution relies on + // the worktree path; a thread with neither now fails loudly by design. + worktreePath: "/tmp/t3-review-test-worktree", + latestTurn: null, + createdAt: "2026-07-20T00:00:00.000Z", + updatedAt: "2026-07-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...overrides, + } as OrchestrationThread; +} + +function makeMessage(input: { + readonly id: string; + readonly role: "user" | "assistant" | "system"; + readonly text: string; + readonly streaming?: boolean; +}): OrchestrationThread["messages"][number] { + return { + id: input.id as OrchestrationThread["messages"][number]["id"], + role: input.role, + text: input.text, + turnId: null, + streaming: input.streaming ?? false, + createdAt: "2026-07-20T00:00:00.000Z", + updatedAt: "2026-07-20T00:00:00.000Z", + }; +} + describe("ReviewService", () => { it.effect("rejects diff preview cwd outside the configured workspace roots", () => Effect.gen(function* () { @@ -97,4 +256,392 @@ describe("ReviewService", () => { assert.deepStrictEqual(detectCalls, []); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("summarizeThread fails with ReviewThreadNotFoundError for unknown threads", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review + .summarizeThread({ threadId: ThreadId.make("thread_missing"), canSettleNow: true }) + .pipe(Effect.flip); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir }))); + + assert.strictEqual(error._tag, "ReviewThreadNotFoundError"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("summarizeThread passes transcript context and returns the review", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const reviewCalls: Array = []; + const thread = makeThread({ + messages: [ + makeMessage({ id: "msg_1", role: "user", text: "Fix the settle default" }), + makeMessage({ id: "msg_2", role: "assistant", text: "Done, merged in PR #1" }), + makeMessage({ id: "msg_3", role: "assistant", text: "streaming...", streaming: true }), + ], + }); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.summarizeThread({ threadId: thread.id, canSettleNow: true }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + reviewCalls, + generateThreadReview: () => + Effect.succeed({ + summary: "Bumped the settle default; PR merged.", + nextStep: "Nothing left — settle this thread.", + suggestedTitle: "Bump settle default", + recommendSettle: true, + settleReason: "PR merged, nothing pending.", + }), + }), + ), + ); + + assert.strictEqual(result.threadId, thread.id); + assert.strictEqual(result.summary, "Bumped the settle default; PR merged."); + assert.strictEqual(result.suggestedTitle, "Bump settle default"); + assert.strictEqual(result.recommendSettle, true); + assert.strictEqual(result.settleReason, "PR merged, nothing pending."); + + assert.strictEqual(reviewCalls.length, 1); + const call = reviewCalls[0]!; + assert.strictEqual(call.title, "New thread"); + assert.strictEqual(call.isActive, false); + assert.strictEqual(call.firstUserMessage, "Fix the settle default"); + // Streaming messages are excluded from the transcript. + assert.deepStrictEqual( + call.recentMessages.map((message) => message.text), + ["Fix the settle default", "Done, merged in PR #1"], + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("summarizeThread never recommends settling when canSettleNow is false", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const reviewCalls: Array = []; + const thread = makeThread({}); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.summarizeThread({ threadId: thread.id, canSettleNow: false }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + reviewCalls, + // Model misbehaves and recommends settle anyway. + generateThreadReview: () => + Effect.succeed({ + summary: "Still working.", + nextStep: "Wait for the agent.", + suggestedTitle: null, + recommendSettle: true, + settleReason: "Looks done to me.", + }), + }), + ), + ); + + assert.strictEqual(reviewCalls[0]?.isActive, true); + assert.strictEqual(result.recommendSettle, false); + assert.strictEqual(result.settleReason, null); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "summarizeThread overrides a stale client canSettleNow when the projection shows activity", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-review-workspace-", + }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const reviewCalls: Array = []; + const thread = makeThread({}); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + // Client believed the thread was settleable, but the server-side + // shell says approvals are pending. + return yield* review.summarizeThread({ threadId: thread.id, canSettleNow: true }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + reviewCalls, + shellOverrides: { hasPendingApprovals: true }, + generateThreadReview: () => + Effect.succeed({ + summary: "Looks finished.", + nextStep: "Settle this thread.", + suggestedTitle: null, + recommendSettle: true, + settleReason: "Done.", + }), + }), + ), + ); + + assert.strictEqual(reviewCalls[0]?.isActive, true); + assert.strictEqual(result.recommendSettle, false); + assert.strictEqual(result.settleReason, null); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("summarizeThread fails loudly when no workspace cwd can be resolved", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + // No worktree and (per the mock) no project shell: cwd is unresolvable. + const thread = makeThread({ worktreePath: null }); + + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review + .summarizeThread({ threadId: thread.id, canSettleNow: true }) + .pipe(Effect.flip); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, thread }))); + + assert.strictEqual(error._tag, "TextGenerationError"); + if (error._tag === "TextGenerationError") { + assert.match(error.detail, /Unable to resolve a workspace directory/); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("summarizeThread attaches PR status and prompt context when gh finds a PR", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const reviewCalls: Array = []; + const thread = makeThread({ branch: "feat/settle-default" }); + const ghPayload = ghPrViewJson({ + number: 4415, + url: "https://github.com/pingdotgg/t3code/pull/4415", + state: "OPEN", + reviewDecision: "APPROVED", + mergeable: "MERGEABLE", + statusCheckRollup: [{ conclusion: "SUCCESS" }, { conclusion: "SKIPPED" }], + comments: [ + { author: { login: "reviewer" }, body: "LGTM", createdAt: "2026-07-24T00:00:00Z" }, + ], + }); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.summarizeThread({ threadId: thread.id, canSettleNow: true }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + reviewCalls, + ghExecute: () => + Effect.succeed({ + exitCode: 0, + stdout: ghPayload, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } as never), + }), + ), + ); + + assert.strictEqual(result.prStatus?.number, 4415); + assert.strictEqual(result.prStatus?.state, "open"); + assert.strictEqual(result.prStatus?.mergeReady, true); + assert.strictEqual(result.prStatus?.checksPassing, true); + assert.strictEqual(reviewCalls[0]?.pullRequest?.number, 4415); + assert.strictEqual(reviewCalls[0]?.pullRequest?.recentComments[0]?.body, "LGTM"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("summarizeThread degrades to no PR context when gh fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const reviewCalls: Array = []; + const thread = makeThread({ branch: "feat/no-pr" }); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.summarizeThread({ threadId: thread.id, canSettleNow: true }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + reviewCalls, + ghExecute: (ghInput) => + Effect.fail( + new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: ghInput.cwd, + cause: "gh missing in test", + }) as never, + ), + }), + ), + ); + + assert.strictEqual(result.prStatus, undefined); + assert.strictEqual(reviewCalls[0]?.pullRequest, undefined); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("mergePullRequest reports conflict when the branch no longer merges cleanly", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const thread = makeThread({ branch: "feat/conflicting" }); + const ghPayload = ghPrViewJson({ + number: 77, + url: "https://github.com/x/y/pull/77", + state: "OPEN", + reviewDecision: "APPROVED", + mergeable: "CONFLICTING", + statusCheckRollup: [{ conclusion: "SUCCESS" }], + comments: [], + }); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.mergePullRequest({ threadId: thread.id, pullRequestNumber: 77 }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + ghExecute: () => + Effect.succeed({ + exitCode: 0, + stdout: ghPayload, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } as never), + }), + ), + ); + + assert.strictEqual(result.outcome, "conflict"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("mergePullRequest merges and reports merged for a ready PR", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const thread = makeThread({ branch: "feat/ready" }); + const ghCalls: string[] = []; + const ghPayload = ghPrViewJson({ + number: 88, + url: "https://github.com/x/y/pull/88", + state: "OPEN", + reviewDecision: "APPROVED", + mergeable: "MERGEABLE", + statusCheckRollup: [{ conclusion: "SUCCESS" }], + comments: [], + }); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.mergePullRequest({ threadId: thread.id, pullRequestNumber: 88 }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + ghExecute: (ghInput) => { + ghCalls.push(ghInput.args.join(" ")); + return Effect.succeed({ + exitCode: 0, + stdout: ghInput.args[1] === "view" ? ghPayload : "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } as never); + }, + }), + ), + ); + + assert.strictEqual(result.outcome, "merged"); + assert.ok(ghCalls.some((call) => call.startsWith("pr merge 88"))); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("summarizeThread treats a queued turn start as active", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const reviewCalls: Array = []; + const thread = makeThread({}); + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.summarizeThread({ threadId: thread.id, canSettleNow: true }); + }).pipe( + Effect.provide( + makeLayer({ + workspaceRoot, + baseDir, + thread, + reviewCalls, + // A just-sent user message with no adopting turn: session still + // null, but the thread has in-flight work. it.effect runs on the + // TestClock (epoch 0), so "just sent" is the epoch. + shellOverrides: { + latestUserMessageAt: "1970-01-01T00:00:00.000Z", + latestTurn: null, + session: null, + }, + generateThreadReview: () => + Effect.succeed({ + summary: "Looks finished.", + nextStep: "Settle this thread.", + suggestedTitle: null, + recommendSettle: true, + settleReason: "Done.", + }), + }), + ), + ); + + assert.strictEqual(reviewCalls[0]?.isActive, true); + assert.strictEqual(result.recommendSettle, false); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index db1dc5bc8d2..c6d951f6123 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -3,17 +3,35 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + import { + ReviewMergeError, + ReviewThreadNotFoundError, + TextGenerationError, VcsRepositoryDetectionError, VcsUnsupportedOperationError, type ReviewDiffPreviewError, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, + type ReviewMergePullRequestError, + type ReviewMergePullRequestInput, + type ReviewMergePullRequestResult, + type ReviewThreadPrStatus, + type ReviewThreadSummaryError, + type ReviewThreadSummaryInput, + type ReviewThreadSummaryResult, } from "@t3tools/contracts"; +import { resolveThreadWorkspaceCwd } from "../checkpointing/Utils.ts"; import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -23,15 +41,192 @@ export class ReviewService extends Context.Service< readonly getDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; + readonly summarizeThread: ( + input: ReviewThreadSummaryInput, + ) => Effect.Effect; + readonly mergePullRequest: ( + input: ReviewMergePullRequestInput, + ) => Effect.Effect; } >()("t3/review/ReviewService") {} +// --------------------------------------------------------------------------- +// PR investigation +// --------------------------------------------------------------------------- + +/** Shape of `gh pr view --json ` we rely on. Decoded leniently: + a schema miss degrades to "no PR context", never a failed review. */ +const GhPrView = Schema.Struct({ + number: Schema.Int, + url: Schema.String, + state: Schema.String, + reviewDecision: Schema.optionalKey(Schema.NullOr(Schema.String)), + mergeable: Schema.optionalKey(Schema.NullOr(Schema.String)), + statusCheckRollup: Schema.optionalKey( + Schema.NullOr( + Schema.Array(Schema.Struct({ conclusion: Schema.optionalKey(Schema.NullOr(Schema.String)) })), + ), + ), + comments: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + author: Schema.optionalKey(Schema.NullOr(Schema.Struct({ login: Schema.String }))), + body: Schema.String, + createdAt: Schema.String, + }), + ), + ), + ), +}); +const decodeGhPrView = Schema.decodeUnknownEffect(Schema.fromJsonString(GhPrView)); + +const GH_PR_VIEW_FIELDS = "number,url,state,reviewDecision,mergeable,statusCheckRollup,comments"; +const PR_COMMENT_CONTEXT_COUNT = 5; +const PR_COMMENT_CHAR_LIMIT = 500; + +export interface ThreadPrContext { + readonly status: ReviewThreadPrStatus; + /** Pre-rendered recent-comments block for the review prompt. */ + readonly recentComments: ReadonlyArray<{ + readonly author: string; + readonly createdAt: string; + readonly body: string; + }>; +} + +function ghStateToPrState(state: string): "open" | "closed" | "merged" | null { + const normalized = state.trim().toUpperCase(); + if (normalized === "OPEN") return "open"; + if (normalized === "CLOSED") return "closed"; + if (normalized === "MERGED") return "merged"; + return null; +} + +function rollupToChecksPassing( + rollup: ReadonlyArray<{ conclusion?: string | null }> | null | undefined, +): boolean | null { + if (!rollup || rollup.length === 0) return null; + // Neutral/skipped conclusions don't block; pending (null conclusion on some + // check types) and failures do. + const blocking = rollup.some((check) => { + const conclusion = check.conclusion?.trim().toUpperCase() ?? ""; + return !["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion); + }); + return !blocking; +} + +function toPrStatus(view: typeof GhPrView.Type): ReviewThreadPrStatus | null { + const state = ghStateToPrState(view.state); + if (state === null) return null; + const reviewDecision = view.reviewDecision?.trim() || null; + const checksPassing = rollupToChecksPassing(view.statusCheckRollup ?? null); + const mergeable = + view.mergeable == null + ? null + : view.mergeable.trim().toUpperCase() === "MERGEABLE" + ? true + : view.mergeable.trim().toUpperCase() === "CONFLICTING" + ? false + : null; + const mergeReady = + state === "open" && + mergeable === true && + checksPassing !== false && + reviewDecision !== "CHANGES_REQUESTED" && + reviewDecision !== "REVIEW_REQUIRED"; + return { + number: view.number, + url: view.url, + state, + reviewDecision, + checksPassing, + mergeable, + mergeReady, + recentCommentCount: view.comments?.length ?? 0, + }; +} + +// Mirrors QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts and +// the decider's thread.settle guard. +const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +/** A user message no turn has adopted yet is in-flight work even though the + session is still null. Mirrors the decider's settle guard and the + client's hasQueuedTurnStart: newest user message strictly newer than + every latestTurn timestamp, within the adoption grace window, and not + already surfaced as a failed session start. */ +function hasQueuedTurnStart( + shell: { + readonly latestUserMessageAt: string | null; + readonly latestTurn: { + readonly requestedAt: string | null; + readonly startedAt: string | null; + readonly completedAt: string | null; + } | null; + readonly session: { readonly status: string } | null; + }, + nowMs: number, +): boolean { + if (shell.latestUserMessageAt === null) return false; + if (shell.session?.status === "error") return false; + const messageAt = Date.parse(shell.latestUserMessageAt); + if (Number.isNaN(messageAt)) return false; + // Bounded on both sides: message timestamps originate on client devices, + // so a clock ahead of the server must not hold the queued state open. + if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; + const turn = shell.latestTurn; + if (turn === null) return true; + return [turn.requestedAt, turn.startedAt, turn.completedAt].every( + (candidate) => candidate === null || Date.parse(candidate) < messageAt, + ); +} + export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const textGeneration = yield* TextGeneration.TextGeneration; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const gitHubCli = yield* GitHubCli.GitHubCli; + + /** Look up the thread branch's PR with one `gh pr view`. Every failure — + no PR, gh unauthenticated, schema drift — degrades to null: PR context + enriches a review but must never fail it. */ + const investigateThreadPr = Effect.fn("ReviewService.investigateThreadPr")(function* ( + cwd: string, + branch: string, + ): Effect.fn.Return { + const context = yield* gitHubCli + .execute({ + cwd, + args: ["pr", "view", branch, "--json", GH_PR_VIEW_FIELDS], + timeoutMs: 20_000, + }) + .pipe( + Effect.flatMap((output) => decodeGhPrView(output.stdout)), + Effect.map((view): ThreadPrContext | null => { + const status = toPrStatus(view); + if (status === null) return null; + const recentComments = (view.comments ?? []) + .slice(-PR_COMMENT_CONTEXT_COUNT) + .map((comment) => ({ + author: comment.author?.login ?? "unknown", + createdAt: comment.createdAt, + body: + comment.body.length > PR_COMMENT_CHAR_LIMIT + ? `${comment.body.slice(0, PR_COMMENT_CHAR_LIMIT)}…` + : comment.body, + })); + return { status, recentComments }; + }), + Effect.catch(() => Effect.succeed(null)), + ); + return context; + }); const canonicalizePath = (value: string) => { const resolvedPath = path.resolve(value); @@ -106,8 +301,234 @@ export const make = Effect.gen(function* () { return yield* getDriverDiffPreview(input); }); + const summarizeThread: ReviewService["Service"]["summarizeThread"] = Effect.fn( + "ReviewService.summarizeThread", + )(function* (input) { + const mapProjectionError = (cause: unknown) => + new TextGenerationError({ + operation: "generateThreadReview", + detail: "Failed to read the thread projection for review.", + cause, + }); + + const threadOption = yield* projectionSnapshotQuery + .getThreadDetailById(input.threadId) + .pipe(Effect.mapError(mapProjectionError)); + if (Option.isNone(threadOption)) { + return yield* new ReviewThreadNotFoundError({ threadId: input.threadId }); + } + const thread = threadOption.value; + + const projectOption = yield* projectionSnapshotQuery + .getProjectShellById(thread.projectId) + .pipe(Effect.mapError(mapProjectionError)); + // No silent process.cwd() fallback: the cwd is handed to the provider + // CLI spawn, which reads local context files (AGENTS.md, CLAUDE.md) from + // it — reviewing "the server's own directory" would leak unrelated + // content into an external LLM prompt. Fail the item instead; the client + // shows it as a retry-able error card. + const cwd = resolveThreadWorkspaceCwd({ + thread, + projects: Option.isSome(projectOption) ? [projectOption.value] : [], + }); + if (cwd === undefined) { + return yield* new TextGenerationError({ + operation: "generateThreadReview", + detail: `Unable to resolve a workspace directory for thread '${input.threadId}'.`, + }); + } + + const settled = thread.messages.filter((message) => !message.streaming); + const firstUserMessage = settled.find((message) => message.role === "user")?.text ?? null; + const recentMessages = settled.map((message) => ({ + role: message.role, + text: message.text, + })); + // Never trust the client's canSettleNow alone: re-derive activity from the + // server's own projection so a session that started (or approval/input + // that appeared) after the client read its shell still blocks settling. + // The shell is deliberately read AFTER the transcript: the two reads are + // separate transactions, and reading the activity view last means a + // session starting in between is still caught. The reverse skew (activity + // ending in between) only staleness-affects the summary text — an actual + // settle is re-validated by the client at apply time and by the decider's + // thread.settle guards, so a recommendation can never settle live work. + const shellOption = yield* projectionSnapshotQuery + .getThreadShellById(input.threadId) + .pipe(Effect.mapError(mapProjectionError)); + const shell = Option.isSome(shellOption) ? shellOption.value : null; + const now = yield* DateTime.now; + const serverSideActive = + shell === null || + shell.hasPendingApprovals || + shell.hasPendingUserInput || + shell.session?.status === "starting" || + shell.session?.status === "running" || + hasQueuedTurnStart(shell, DateTime.toEpochMillis(now)); + const canSettleNow = input.canSettleNow && !serverSideActive; + const isActive = !canSettleNow; + + const { textGenerationModelSelection: modelSelection } = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateThreadReview", + detail: "Failed to load server settings for review generation.", + cause, + }), + ), + ); + + // Deeper investigation: live PR state + recent comments, when the + // thread has a branch. Failures degrade to "no PR context". + const prContext = + thread.branch !== null ? yield* investigateThreadPr(cwd, thread.branch) : null; + + const generated = yield* textGeneration.generateThreadReview({ + cwd, + title: thread.title, + isActive, + firstUserMessage, + recentMessages, + modelSelection, + ...(prContext !== null + ? { + pullRequest: { + number: prContext.status.number, + state: prContext.status.state, + reviewDecision: prContext.status.reviewDecision, + checksPassing: prContext.status.checksPassing, + mergeable: prContext.status.mergeable, + recentComments: prContext.recentComments, + }, + } + : {}), + }); + + // Belt and braces on top of prompt rules + normalizeThreadReview: an + // active thread must never carry a settle recommendation. + const recommendSettle = generated.recommendSettle && canSettleNow; + + // Latest ready checkpoint approximates the thread's cumulative diff — + // checkpoints stack per turn, so the newest one reflects total change. + const latestCheckpoint = thread.checkpoints + .toReversed() + .find((checkpoint) => checkpoint.status === "ready"); + const diffStats = latestCheckpoint + ? { + files: latestCheckpoint.files.length, + additions: latestCheckpoint.files.reduce((sum, file) => sum + file.additions, 0), + deletions: latestCheckpoint.files.reduce((sum, file) => sum + file.deletions, 0), + } + : undefined; + + return { + threadId: input.threadId, + summary: generated.summary, + nextStep: generated.nextStep, + suggestedTitle: generated.suggestedTitle, + recommendSettle, + settleReason: recommendSettle ? generated.settleReason : null, + ...(diffStats !== undefined ? { diffStats } : {}), + ...(prContext !== null ? { prStatus: prContext.status } : {}), + }; + }); + + const mergePullRequest: ReviewService["Service"]["mergePullRequest"] = Effect.fn( + "ReviewService.mergePullRequest", + )(function* (input) { + const mapProjectionError = (cause: unknown) => + new ReviewMergeError({ + threadId: input.threadId, + detail: `Failed to read the thread projection: ${String(cause)}`, + }); + const threadOption = yield* projectionSnapshotQuery + .getThreadDetailById(input.threadId) + .pipe(Effect.mapError(mapProjectionError)); + if (Option.isNone(threadOption)) { + return yield* new ReviewThreadNotFoundError({ threadId: input.threadId }); + } + const thread = threadOption.value; + const projectOption = yield* projectionSnapshotQuery + .getProjectShellById(thread.projectId) + .pipe(Effect.mapError(mapProjectionError)); + const cwd = resolveThreadWorkspaceCwd({ + thread, + projects: Option.isSome(projectOption) ? [projectOption.value] : [], + }); + if (cwd === undefined || thread.branch === null) { + return yield* new ReviewMergeError({ + threadId: input.threadId, + detail: "Thread has no resolvable workspace or branch to merge from.", + }); + } + + // Re-validate against LIVE GitHub state: in a merge queue, an earlier + // merge may have landed since the review ran and made this branch + // conflict (or someone merged/closed the PR out of band). + const prContext = yield* investigateThreadPr(cwd, thread.branch); + if (prContext === null || prContext.status.number !== input.pullRequestNumber) { + return { + threadId: input.threadId, + outcome: "not-ready" as const, + detail: "The pull request could not be re-validated against GitHub.", + }; + } + const status = prContext.status; + if (status.state !== "open") { + return { + threadId: input.threadId, + outcome: "already-closed" as const, + detail: `PR #${status.number} is already ${status.state}.`, + }; + } + if (status.mergeable === false) { + return { + threadId: input.threadId, + outcome: "conflict" as const, + detail: `PR #${status.number} no longer merges cleanly onto its base branch.`, + }; + } + if (!status.mergeReady) { + return { + threadId: input.threadId, + outcome: "not-ready" as const, + detail: `PR #${status.number} is not merge-ready (checks: ${String(status.checksPassing)}, review: ${status.reviewDecision ?? "none"}).`, + }; + } + + const mergeResult = yield* gitHubCli + .execute({ + cwd, + args: ["pr", "merge", String(status.number), "--squash"], + timeoutMs: 60_000, + }) + .pipe( + Effect.map(() => ({ merged: true as const, detail: null as string | null })), + Effect.catch((error) => + Effect.succeed({ + merged: false as const, + detail: error instanceof Error ? error.message : String(error), + }), + ), + ); + if (!mergeResult.merged) { + // gh merge failures at this point are usually races (base advanced, + // check flipped). Surface as conflict so the queue hands it to the + // thread's agent rather than aborting the whole run. + return { + threadId: input.threadId, + outcome: "conflict" as const, + detail: mergeResult.detail ?? "gh pr merge failed.", + }; + } + return { threadId: input.threadId, outcome: "merged" as const, detail: null }; + }); + return ReviewService.of({ getDiffPreview, + summarizeThread, + mergePullRequest, }); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..e2d943a5f4a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -86,7 +86,9 @@ import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -522,6 +524,24 @@ const buildAppUnderTest = (options?: { : ReviewService.layer.pipe( Layer.provideMerge(gitVcsDriverLayer), Layer.provide(vcsDriverRegistryLayer), + Layer.provide( + Layer.mock(TextGeneration.TextGeneration)({ + generateThreadReview: () => Effect.die("TextGeneration not stubbed in this test"), + }), + ), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getThreadDetailById: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + ...options?.layers?.projectionSnapshotQuery, + }), + ), + Layer.provide( + Layer.mock(GitHubCli.GitHubCli)({ + execute: () => Effect.die("GitHubCli not stubbed in this test"), + }), + ), + Layer.provide(ServerSettings.layerTest()), ); const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66b9823afb3..4755f308699 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -222,6 +222,8 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l const ReviewLayerLive = ReviewService.layer.pipe( Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), + Layer.provide(TextGeneration.layer), + Layer.provide(GitHubCli.layer), ); const VcsLayerLive = Layer.empty.pipe( diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 453bb62b728..fc5108f2008 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -23,10 +23,12 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { normalizeCliError, + normalizeThreadReview, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, @@ -85,7 +87,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateThreadReview", value: unknown, detail: string, ): Effect.Effect => @@ -115,7 +118,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; cwd: string; prompt: string; outputSchemaJson: S; @@ -355,10 +359,32 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }; }); + const generateThreadReview: TextGeneration.TextGeneration["Service"]["generateThreadReview"] = + Effect.fn("ClaudeTextGeneration.generateThreadReview")(function* (input) { + const { prompt, outputSchema } = buildThreadReviewPrompt({ + title: input.title, + isActive: input.isActive, + firstUserMessage: input.firstUserMessage, + recentMessages: input.recentMessages, + pullRequest: input.pullRequest, + }); + + const generated = yield* runClaudeJson({ + operation: "generateThreadReview", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return normalizeThreadReview(generated, input.isActive); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadReview, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 6a5c0df43c7..4c1b77f1d28 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -21,10 +21,12 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { normalizeCliError, + normalizeThreadReview, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, @@ -98,7 +100,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateThreadReview", value: unknown, ): Effect.Effect => encodeJsonString(value).pipe( @@ -117,7 +120,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateThreadReview", attachments: TextGeneration.BranchNameGenerationInput["attachments"], ): Effect.fn.Return { if (!attachments || attachments.length === 0) { @@ -159,7 +163,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; cwd: string; prompt: string; outputSchemaJson: S; @@ -398,10 +403,33 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateThreadReview: TextGeneration.TextGeneration["Service"]["generateThreadReview"] = + Effect.fn("CodexTextGeneration.generateThreadReview")(function* (input) { + const { prompt, outputSchema } = buildThreadReviewPrompt({ + title: input.title, + isActive: input.isActive, + firstUserMessage: input.firstUserMessage, + recentMessages: input.recentMessages, + pullRequest: input.pullRequest, + }); + + const generated = yield* runCodexJson({ + operation: "generateThreadReview", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths: [], + modelSelection: input.modelSelection, + }); + + return normalizeThreadReview(generated, input.isActive); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadReview, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 6d1e5d02db3..781b4b1bd14 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -15,9 +15,11 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { + normalizeThreadReview, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, @@ -54,7 +56,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; cwd: string; prompt: string; outputSchemaJson: S; @@ -255,10 +258,32 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateThreadReview: TextGeneration.TextGeneration["Service"]["generateThreadReview"] = + Effect.fn("CursorTextGeneration.generateThreadReview")(function* (input) { + const { prompt, outputSchema } = buildThreadReviewPrompt({ + title: input.title, + isActive: input.isActive, + firstUserMessage: input.firstUserMessage, + recentMessages: input.recentMessages, + pullRequest: input.pullRequest, + }); + + const generated = yield* runCursorJson({ + operation: "generateThreadReview", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return normalizeThreadReview(generated, input.isActive); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadReview, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index fd367acdf4c..b21b324636e 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -16,9 +16,11 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { + normalizeThreadReview, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, @@ -52,7 +54,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; cwd: string; prompt: string; outputSchemaJson: S; @@ -247,10 +250,32 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateThreadReview: TextGeneration.TextGeneration["Service"]["generateThreadReview"] = + Effect.fn("GrokTextGeneration.generateThreadReview")(function* (input) { + const { prompt, outputSchema } = buildThreadReviewPrompt({ + title: input.title, + isActive: input.isActive, + firstUserMessage: input.firstUserMessage, + recentMessages: input.recentMessages, + pullRequest: input.pullRequest, + }); + + const generated = yield* runGrokJson({ + operation: "generateThreadReview", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return normalizeThreadReview(generated, input.isActive); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadReview, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 1f94f970692..1bfc624cecc 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -22,10 +22,12 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { + normalizeThreadReview, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, @@ -39,6 +41,7 @@ const OpenCodeTextGenerationOperation = Schema.Literals([ "generatePrContent", "generateBranchName", "generateThreadTitle", + "generateThreadReview", ]); type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; @@ -253,7 +256,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; }) => sharedServerMutex.withPermit( Effect.gen(function* () { @@ -611,10 +615,32 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }; }); + const generateThreadReview: TextGeneration.TextGeneration["Service"]["generateThreadReview"] = + Effect.fn("OpenCodeTextGeneration.generateThreadReview")(function* (input) { + const { prompt, outputSchema } = buildThreadReviewPrompt({ + title: input.title, + isActive: input.isActive, + firstUserMessage: input.firstUserMessage, + recentMessages: input.recentMessages, + pullRequest: input.pullRequest, + }); + + const generated = yield* runOpenCodeJson({ + operation: "generateThreadReview", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return normalizeThreadReview(generated, input.isActive); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadReview, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index 9bccb9c1fc5..b83dd194dbf 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -21,6 +21,8 @@ const makeStubTextGeneration = ( generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + generateThreadReview: () => + Effect.die("generateThreadReview stub not configured for this test"), ...overrides, }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index e62a79afe78..39f100691ab 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -67,6 +67,39 @@ export interface ThreadTitleGenerationResult { title: string; } +export interface ThreadReviewPullRequestContext { + number: number; + state: "open" | "closed" | "merged"; + reviewDecision: string | null; + checksPassing: boolean | null; + mergeable: boolean | null; + recentComments: ReadonlyArray<{ author: string; createdAt: string; body: string }>; +} + +export interface ThreadReviewGenerationInput { + cwd: string; + title: string; + /** Active threads (running session or awaiting the user) never get a settle + recommendation. */ + isActive: boolean; + firstUserMessage: string | null; + recentMessages: ReadonlyArray<{ role: "user" | "assistant" | "system"; text: string }>; + /** Live PR state + recent comments when the thread has one. */ + pullRequest?: ThreadReviewPullRequestContext | undefined; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface ThreadReviewGenerationResult { + summary: string; + /** One imperative sentence: the user's single next action. */ + nextStep: string; + /** Null when the current title is still accurate. */ + suggestedTitle: string | null; + recommendSettle: boolean; + settleReason: string | null; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, @@ -74,6 +107,7 @@ export interface TextGenerationService { generatePrContent(input: PrContentGenerationInput): Promise; generateBranchName(input: BranchNameGenerationInput): Promise; generateThreadTitle(input: ThreadTitleGenerationInput): Promise; + generateThreadReview(input: ThreadReviewGenerationInput): Promise; } /** @@ -109,6 +143,14 @@ export class TextGeneration extends Context.Service< readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; + + /** + * Review a thread transcript for the work-review sweep: summary, + * optional corrected title, and a settle recommendation. + */ + readonly generateThreadReview: ( + input: ThreadReviewGenerationInput, + ) => Effect.Effect; } >()("t3/textGeneration/TextGeneration") {} @@ -119,7 +161,8 @@ type TextGenerationOp = | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadReview"; const resolveInstance = ( registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], @@ -159,6 +202,10 @@ export const makeTextGenerationFromRegistry = ( resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), ), + generateThreadReview: (input) => + resolveInstance(registry, "generateThreadReview", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateThreadReview(input)), + ), }); export const make = Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index b67e8b93c4a..e568801fd56 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -4,9 +4,14 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadReviewPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; -import { normalizeCliError, sanitizeThreadTitle } from "./TextGenerationUtils.ts"; +import { + normalizeCliError, + normalizeThreadReview, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; import { TextGenerationError } from "@t3tools/contracts"; describe("buildCommitMessagePrompt", () => { @@ -136,6 +141,91 @@ describe("buildThreadTitlePrompt", () => { }); }); +describe("buildThreadReviewPrompt", () => { + it("includes title, first user message, and transcript", () => { + const result = buildThreadReviewPrompt({ + title: "New thread", + isActive: false, + firstUserMessage: "Fix the settle default", + recentMessages: [ + { role: "user", text: "Fix the settle default" }, + { role: "assistant", text: "Done, merged." }, + ], + }); + + expect(result.prompt).toContain("Current title: New thread"); + expect(result.prompt).toContain("Fix the settle default"); + expect(result.prompt).toContain("[assistant] Done, merged."); + expect(result.prompt).not.toContain("ACTIVE"); + }); + + it("adds the never-settle rule for active threads", () => { + const result = buildThreadReviewPrompt({ + title: "Working thread", + isActive: true, + firstUserMessage: "Do a thing", + recentMessages: [], + }); + + expect(result.prompt).toContain("recommendSettle must be false"); + expect(result.prompt).toContain("(no messages)"); + }); + + it("caps the transcript to the most recent 20 messages", () => { + const result = buildThreadReviewPrompt({ + title: "Long thread", + isActive: false, + firstUserMessage: "start", + recentMessages: Array.from({ length: 30 }, (_, index) => ({ + role: "assistant" as const, + text: `message-${index}`, + })), + }); + + expect(result.prompt).not.toContain("message-9\n"); + expect(result.prompt).toContain("message-10"); + expect(result.prompt).toContain("message-29"); + }); +}); + +describe("normalizeThreadReview", () => { + const raw = { + summary: " Work finished. ", + nextStep: " Settle it. ", + suggestedTitle: '"Ship settle default"', + recommendSettle: true, + settleReason: " PR merged. ", + }; + + it("sanitizes fields and keeps settle recommendation for inactive threads", () => { + expect(normalizeThreadReview(raw, false)).toEqual({ + summary: "Work finished.", + nextStep: "Settle it.", + suggestedTitle: "Ship settle default", + recommendSettle: true, + settleReason: "PR merged.", + }); + }); + + it("forces recommendSettle off for active threads even if the model says settle", () => { + const result = normalizeThreadReview(raw, true); + expect(result.recommendSettle).toBe(false); + expect(result.settleReason).toBeNull(); + }); + + it("drops empty and placeholder title suggestions", () => { + expect( + normalizeThreadReview({ ...raw, suggestedTitle: " " }, false).suggestedTitle, + ).toBeNull(); + expect( + normalizeThreadReview({ ...raw, suggestedTitle: "New thread" }, false).suggestedTitle, + ).toBeNull(); + expect( + normalizeThreadReview({ ...raw, suggestedTitle: null }, false).suggestedTitle, + ).toBeNull(); + }); +}); + describe("sanitizeThreadTitle", () => { it("truncates long titles with the shared sidebar-safe limit", () => { expect( diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 6015e83b5d4..b0a5c562507 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -216,3 +216,116 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { return { prompt, outputSchema }; } + +// --------------------------------------------------------------------------- +// Thread review (work-review sweep) +// --------------------------------------------------------------------------- + +export interface ThreadReviewMessage { + role: "user" | "assistant" | "system"; + text: string; +} + +export interface ThreadReviewPromptInput { + title: string; + /** True when the thread has a running session or pending approvals/input. + Active threads must never get a settle recommendation. */ + isActive: boolean; + firstUserMessage: string | null; + recentMessages: ReadonlyArray; + pullRequest?: + | { + number: number; + state: "open" | "closed" | "merged"; + reviewDecision: string | null; + checksPassing: boolean | null; + mergeable: boolean | null; + recentComments: ReadonlyArray<{ author: string; createdAt: string; body: string }>; + } + | undefined; +} + +function renderPullRequestSection( + pullRequest: NonNullable, +): string { + const lines = [ + `PR #${pullRequest.number}: state=${pullRequest.state}`, + `review decision: ${pullRequest.reviewDecision ?? "none required"}`, + `CI checks passing: ${pullRequest.checksPassing === null ? "unknown" : String(pullRequest.checksPassing)}`, + `mergeable: ${pullRequest.mergeable === null ? "unknown" : String(pullRequest.mergeable)}`, + ]; + if (pullRequest.recentComments.length > 0) { + lines.push("Recent PR comments (newest last):"); + for (const comment of pullRequest.recentComments) { + lines.push(`[${comment.author} @ ${comment.createdAt}] ${comment.body}`); + } + } + return lines.join("\n"); +} + +const THREAD_REVIEW_RECENT_MESSAGE_COUNT = 20; +const THREAD_REVIEW_MESSAGE_CHAR_LIMIT = 2_000; + +export function buildThreadReviewPrompt(input: ThreadReviewPromptInput) { + const recent = input.recentMessages.slice(-THREAD_REVIEW_RECENT_MESSAGE_COUNT); + const transcript = recent + .map( + (message) => + `[${message.role}] ${limitSection(message.text, THREAD_REVIEW_MESSAGE_CHAR_LIMIT)}`, + ) + .join("\n\n"); + + const prompt = [ + "You review a coding-agent conversation thread and report its state.", + "Return a JSON object with keys: summary, nextStep, suggestedTitle, recommendSettle, settleReason.", + "Rules:", + "- summary: ONE sentence, max ~25 words: what was asked and where it landed", + "- nextStep: a COMMAND to the user, max 10 words. Start with a verb. Never describe status.", + " GOOD: 'Merge PR #4415.'", + " GOOD: 'Answer the agent's question about auth scopes.'", + " GOOD: 'Settle this thread.'", + " GOOD: 'Wait — agent still working.'", + " BAD: 'The requested comparison and hosted write-up are complete, and...' (status recap, not a command)", + " BAD: 'You should probably consider reviewing the PR when you have time.' (hedging filler)", + "- suggestedTitle: a corrected 3-8 word title ONLY if the current title is misleading or a placeholder like 'New thread'; otherwise null", + "- recommendSettle: true ONLY when the work clearly concluded (done, abandoned, or superseded) and nothing awaits the user", + "- settleReason: one short sentence justifying recommendSettle, or null when recommendSettle is false", + "- be conservative: when in doubt, do not suggest a title change and do not recommend settling", + ...(input.isActive + ? [ + "- this thread is still ACTIVE (running or awaiting the user): recommendSettle must be false", + ] + : []), + ...(input.pullRequest + ? [ + "- weigh the PR context heavily: an open PR awaiting the user's review/merge is NOT settleable; a merged PR with no follow-up work is", + "- if the PR is open, CI is green, reviews approve it, and it is mergeable, nextStep should be to merge it", + ] + : []), + "", + `Current title: ${input.title}`, + ...(input.pullRequest + ? [ + "", + "Pull request status:", + limitSection(renderPullRequestSection(input.pullRequest), 6_000), + ] + : []), + "", + "First user message:", + limitSection(input.firstUserMessage ?? "(none)", THREAD_REVIEW_MESSAGE_CHAR_LIMIT), + "", + `Most recent messages (up to ${THREAD_REVIEW_RECENT_MESSAGE_COUNT}):`, + limitSection(transcript.length > 0 ? transcript : "(no messages)", 24_000), + ].join("\n"); + + const outputSchema = Schema.Struct({ + summary: Schema.String, + nextStep: Schema.String, + suggestedTitle: Schema.NullOr(Schema.String), + recommendSettle: Schema.Boolean, + settleReason: Schema.NullOr(Schema.String), + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index ad2911c20f7..7bffe422abe 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -63,6 +63,58 @@ export function sanitizeThreadTitle(raw: string): string { return `${normalized.slice(0, 47).trimEnd()}...`; } +/** + * Normalise a raw thread-review generation into the service result shape: + * collapse whitespace, drop empty/no-op title suggestions, and force + * `recommendSettle` off for active threads regardless of model output. + */ +export function normalizeThreadReview( + raw: { + summary: string; + nextStep: string; + suggestedTitle: string | null; + recommendSettle: boolean; + settleReason: string | null; + }, + isActive: boolean, +): { + summary: string; + nextStep: string; + suggestedTitle: string | null; + recommendSettle: boolean; + settleReason: string | null; +} { + const summary = raw.summary.trim().replace(/\s+/g, " "); + // Backstop against rambling: first sentence only, hard-capped. The prompt + // asks for a <=10-word command, but models drift — never render a recap. + const nextStepFirstSentence = + raw.nextStep + .trim() + .replace(/\s+/g, " ") + .match(/^[^.!?]*[.!?]?/)?.[0] + ?.trim() ?? ""; + const nextStep = + nextStepFirstSentence.length > 80 + ? `${nextStepFirstSentence.slice(0, 77).trimEnd()}...` + : nextStepFirstSentence; + const suggestedTitle = + raw.suggestedTitle && raw.suggestedTitle.trim().length > 0 + ? sanitizeThreadTitle(raw.suggestedTitle) + : null; + const recommendSettle = raw.recommendSettle && !isActive; + const settleReason = + recommendSettle && raw.settleReason && raw.settleReason.trim().length > 0 + ? raw.settleReason.trim().replace(/\s+/g, " ") + : null; + return { + summary: summary.length > 0 ? summary : "No summary available.", + nextStep: nextStep.length > 0 ? nextStep : "Review this thread.", + suggestedTitle: suggestedTitle === "New thread" ? null : suggestedTitle, + recommendSettle, + settleReason, + }; +} + /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ function cliLabel(cliName: string): string { const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..941a421845f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -332,6 +332,8 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope], [WS_METHODS.vcsInit, AuthOrchestrationOperateScope], [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope], + [WS_METHODS.reviewSummarizeThread, AuthReviewWriteScope], + [WS_METHODS.reviewMergePullRequest, AuthReviewWriteScope], [WS_METHODS.terminalOpen, AuthTerminalOperateScope], [WS_METHODS.terminalAttach, AuthTerminalOperateScope], [WS_METHODS.terminalWrite, AuthTerminalOperateScope], @@ -1852,6 +1854,14 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), { "rpc.aggregate": "review", }), + [WS_METHODS.reviewSummarizeThread]: (input) => + observeRpcEffect(WS_METHODS.reviewSummarizeThread, review.summarizeThread(input), { + "rpc.aggregate": "review", + }), + [WS_METHODS.reviewMergePullRequest]: (input) => + observeRpcEffect(WS_METHODS.reviewMergePullRequest, review.mergePullRequest(input), { + "rpc.aggregate": "review", + }), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index e018491348e..fe3ad85aeac 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -28,6 +28,7 @@ import { FolderPlusIcon, GitBranchIcon, EllipsisIcon, + ListChecksIcon, MessageSquareIcon, PlusIcon, SearchIcon, @@ -80,6 +81,8 @@ import { type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; +import { publishSweepChangeRequestStates } from "../reviewSweepStore"; +import { ReviewSweepLaunchDialog } from "./reviewSweep/ReviewSweepLaunchDialog"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; @@ -1162,6 +1165,11 @@ export default function SidebarV2() { }, [], ); + // Mirror PR states to the review-sweep store so its unsettled-candidate + // predicate matches this sidebar's partition (merged-PR auto-settle). + useEffect(() => { + publishSweepChangeRequestStates(changeRequestStateByKey); + }, [changeRequestStateByKey]); // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. @@ -2198,6 +2206,14 @@ export default function SidebarV2() { openCommandPalette({ open: "new-thread-in" }); }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); + // Open the launch modal — the sweep itself runs in the background and + // announces completion with a toast, so no navigation happens here. + const [reviewSweepDialogOpen, setReviewSweepDialogOpen] = useState(false); + const handleReviewSweepClick = useCallback(() => { + if (isMobile) setOpenMobile(false); + setReviewSweepDialogOpen(true); + }, [isMobile, setOpenMobile]); + const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to // chat.new, no platform gating — web users have working shortcuts too. @@ -2207,6 +2223,10 @@ export default function SidebarV2() { return ( <> +
@@ -2231,6 +2251,29 @@ export default function SidebarV2() { ) : null}
+
+ + + } + > + + + Review unsettled work + +
void; +}) { + const router = useRouter(); + const phase = useReviewSweepStore((state) => state.phase); + + // Install the navigator the store's completion toast uses. The dialog is + // mounted for the sidebar's lifetime, so this stays registered. + useEffect(() => { + setSweepResultsNavigator(() => { + void router.navigate({ to: "/review-sweep" }); + }); + return () => setSweepResultsNavigator(null); + }, [router]); + + const handleStarted = () => { + onOpenChange(false); + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Reviewing your work in the background", + description: "You'll get a notification when the results are ready.", + actionVariant: "outline", + actionProps: { + children: "Watch progress", + onClick: () => void router.navigate({ to: "/review-sweep" }), + }, + }), + ); + }; + + return ( + + + + Review unsettled work + + {phase === "running" + ? "A review is already running — results will arrive as a notification." + : "One AI review per unsettled thread, run in the background."} + + + {phase === "running" ? null : } + + + ); +} diff --git a/apps/web/src/components/reviewSweep/ReviewSweepView.tsx b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx new file mode 100644 index 00000000000..43bda96d816 --- /dev/null +++ b/apps/web/src/components/reviewSweep/ReviewSweepView.tsx @@ -0,0 +1,712 @@ +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, +} from "@t3tools/client-runtime/environment"; +import { Link } from "@tanstack/react-router"; +import { + ArchiveIcon, + ArrowRightIcon, + CircleAlertIcon, + GitMergeIcon, + InfoIcon, + ListChecksIcon, + LoaderIcon, + PencilLineIcon, + RotateCcwIcon, + XIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; +import { useClientSettings } from "../../hooks/useSettings"; +import { + applyAllSweepRecommendations, + applySweepSettle, + applySweepTitle, + dismissSweepItem, + isSweepCandidate, + mergeSweepItem, + retrySweepItem, + runMergeQueue, + sortSweepCandidates, + startReviewSweep, + SWEEP_MAX_THREADS, + useReviewSweepStore, + type SweepItem, +} from "../../reviewSweepStore"; +import { + readThreadShell, + useProjects, + useServerConfigs, + useThreadShells, +} from "../../state/entities"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Card } from "../ui/card"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; +import { SidebarInset } from "../ui/sidebar"; +import { Spinner } from "../ui/spinner"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; + +// --------------------------------------------------------------------------- +// Triage buckets: ordered by "how fast can you clear this" — one-click +// settles first, quick title fixes second, then threads that need real +// reading, then no-action-possible tails. +// --------------------------------------------------------------------------- + +type SweepBucket = + | "mergeReady" + | "settle" + | "title" + | "attention" + | "inFlight" + | "failed" + | "reviewing"; + +const BUCKET_ORDER: readonly SweepBucket[] = [ + "mergeReady", + "settle", + "title", + "attention", + "inFlight", + "failed", + "reviewing", +]; + +const BUCKET_META: Record< + SweepBucket, + { title: string; hint: string; accent: string; icon: typeof ArchiveIcon } +> = { + mergeReady: { + title: "Merge ready", + hint: "Open PRs with green CI, approvals, and clean merges.", + accent: "border-s-violet-500/70", + icon: GitMergeIcon, + }, + settle: { + title: "Ready to settle", + hint: "Work concluded — one click clears each from your active list.", + accent: "border-s-emerald-500/70", + icon: ArchiveIcon, + }, + title: { + title: "Title fixes", + hint: "Still in progress, but the name no longer matches the work.", + accent: "border-s-sky-500/70", + icon: PencilLineIcon, + }, + attention: { + title: "Needs your attention", + hint: "Waiting on you — review, input, or a decision.", + accent: "border-s-amber-500/70", + icon: CircleAlertIcon, + }, + inFlight: { + title: "In flight", + hint: "Agents are still working; nothing to do yet.", + accent: "border-s-border", + icon: LoaderIcon, + }, + failed: { + title: "Review failed", + hint: "The reviewer couldn't process these — retry or open them directly.", + accent: "border-s-red-500/70", + icon: CircleAlertIcon, + }, + reviewing: { + title: "Still reviewing", + hint: "", + accent: "border-s-border", + icon: LoaderIcon, + }, +}; + +function classifySweepItem(item: SweepItem): SweepBucket { + if (item.status === "error") return "failed"; + if (item.status !== "done" || item.result === null) return "reviewing"; + // Merge-ready wins over everything: it's the highest-leverage one-click. + // Items stay in the bucket through the queue lifecycle (queued → merging + // → merged/conflicted) so progress is visible in place. + if (item.result.prStatus?.mergeReady && !item.settleApplied) return "mergeReady"; + if (item.result.recommendSettle && !item.settleApplied) return "settle"; + if (item.result.suggestedTitle && !item.titleApplied) return "title"; + // Live shell wins over review-time knowledge: a thread blocked on the user + // right now belongs in "attention" even if the review predates the block. + const shell = readThreadShell(item.ref); + const working = shell?.session?.status === "running" || shell?.session?.status === "starting"; + const blocked = shell?.hasPendingApprovals === true || shell?.hasPendingUserInput === true; + if (blocked) return "attention"; + if (working) return "inFlight"; + return "attention"; +} + +function DiffStatsLabel({ item }: { item: SweepItem }) { + const stats = item.result?.diffStats; + if (!stats || stats.files === 0) return null; + return ( + + +{stats.additions} + −{stats.deletions} + + · {stats.files} {stats.files === 1 ? "file" : "files"} + + + ); +} + +function SweepItemMetaRow({ + item, + projectTitle, + workspaceRoot, +}: { + item: SweepItem; + projectTitle: string; + workspaceRoot: string | null; +}) { + const age = formatRelativeTimeLabel(item.lastActivityAt); + return ( +
+ + {workspaceRoot ? ( + + ) : null} + {projectTitle} + + {item.branch ? ( + + {item.branch} + + ) : null} + {item.result?.prStatus ? ( + + #{item.result.prStatus.number} + + ) : null} + {age ? {age} : null} + +
+ ); +} + +function MergeStatusBadge({ item }: { item: SweepItem }) { + switch (item.mergeStatus) { + case "queued": + return Queued; + case "merging": + return ( + + + Merging + + ); + case "merged": + return Merged; + case "conflicted": + return Conflict — agent rebasing; + case "failed": + return Merge failed; + default: + return null; + } +} + +function SweepItemCard({ + item, + bucket, + projectTitle, + workspaceRoot, +}: { + item: SweepItem; + bucket: SweepBucket; + projectTitle: string; + workspaceRoot: string | null; +}) { + const key = scopedThreadKey(item.ref); + const [applying, setApplying] = useState(false); + const result = item.result; + const meta = BUCKET_META[bucket]; + + const primaryAction = + bucket === "mergeReady" ? ( + item.mergeStatus === "idle" || item.mergeStatus === "failed" ? ( + + ) : null + ) : bucket === "settle" ? ( + + ) : bucket === "title" ? ( + + ) : bucket === "failed" ? ( + + ) : null; + + // The one-line "what should I do" is the card body; the full summary + // lives behind the info icon so the grid stays scannable. Results from + // pre-nextStep servers fall back to settleReason (settle bucket) or the + // summary (attention bucket) so action cards never render bodyless. + const nextStepFallback = + bucket === "settle" + ? (result?.settleReason ?? null) + : bucket === "attention" + ? (result?.summary ?? null) + : null; + const nextStep = item.status === "error" ? null : (result?.nextStep ?? nextStepFallback); + + return ( + +
+ + {item.threadTitle} + + {bucket === "settle" && item.settleApplied ? ( + Settled + ) : null} + {bucket === "mergeReady" ? : null} +
+ {result?.summary ? ( + + + } + > + + + + {result.summary} + + + ) : null} + +
+
+ + + + {item.status === "error" ? ( +

{item.errorMessage}

+ ) : item.mergeStatus === "conflicted" || item.mergeStatus === "failed" ? ( +

{item.mergeDetail}

+ ) : nextStep ? ( +

{nextStep}

+ ) : null} + + {bucket === "title" && result?.suggestedTitle ? ( +
+ + {item.threadTitle} + + + + {result.suggestedTitle} + +
+ ) : null} + {result?.suggestedTitle && item.titleApplied ? ( +

+ Renamed to {result.suggestedTitle}. +

+ ) : null} + + {primaryAction ?
{primaryAction}
: null} +
+ ); +} + +/** Cost-transparency threshold: at or above this many candidate threads the + pre-run screen calls out that the sweep may be slow/expensive. */ +const SWEEP_COST_NOTE_THRESHOLD = 15; + +/** Pre-run summary: how many threads a sweep would cover and which model + each environment's server will use, so starting is an informed choice. + Rendered on the idle /review-sweep page and inside the sidebar's launch + modal. `onStarted` lets the modal close itself when the sweep kicks off. */ +export function SweepPreRunSummary({ onStarted }: { onStarted?: () => void } = {}) { + const shells = useThreadShells(); + const serverConfigs = useServerConfigs(); + const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + // Recompute when the sidebar publishes fresh PR states — merged-PR + // auto-settle changes which threads count as unsettled. + const candidateVersion = useReviewSweepStore((state) => state.candidateVersion); + + const { candidateCount, reviewedCount, modelsByEnvironment } = useMemo(() => { + const now = new Date().toISOString(); + const candidates = sortSweepCandidates( + shells.filter((shell) => + isSweepCandidate(shell, serverConfigs.get(shell.environmentId)?.environment.capabilities, { + now, + autoSettleAfterDays, + }), + ), + ); + // Aggregate only what a run would actually review: the cap is applied + // here, in the same most-recently-active order startReviewSweep uses, + // so the environment/model rows never overstate the calls. + const reviewed = candidates.slice(0, SWEEP_MAX_THREADS); + const models = new Map< + string, + { environmentId: string; label: string; model: string; threads: number } + >(); + for (const shell of reviewed) { + const config = serverConfigs.get(shell.environmentId); + const entry = models.get(shell.environmentId); + if (entry) { + entry.threads += 1; + } else { + const selection = config?.settings.textGenerationModelSelection; + models.set(shell.environmentId, { + environmentId: shell.environmentId, + label: config?.environment.label ?? shell.environmentId, + model: selection ? `${selection.model}` : "server default", + threads: 1, + }); + } + } + return { + candidateCount: candidates.length, + reviewedCount: reviewed.length, + modelsByEnvironment: [...models.values()], + }; + }, [autoSettleAfterDays, candidateVersion, serverConfigs, shells]); + + if (candidateCount === 0) { + return ( + + + All caught up + No unsettled threads to review right now. + + + ); + } + + return ( + + + Review your in-progress work + + This sweep runs one AI review per thread to summarize it, catch stale titles, and find + threads ready to settle. Nothing is applied without your click. + + +
+
+ {reviewedCount}{" "} + {reviewedCount === 1 ? "thread" : "threads"} will be reviewed + {candidateCount > SWEEP_MAX_THREADS + ? ` (${candidateCount - SWEEP_MAX_THREADS} least-recently-active skipped)` + : ""} +
+ {modelsByEnvironment.map((entry) => ( +
+ {entry.label}: {entry.model} · {entry.threads}{" "} + {entry.threads === 1 ? "thread" : "threads"} +
+ ))} + {reviewedCount >= SWEEP_COST_NOTE_THRESHOLD ? ( +
+ Heads up: {reviewedCount} model calls may take a few minutes and use noticeable credits. + You can change the model under Settings → Models. +
+ ) : ( +
Model is configurable under Settings → Models (text generation).
+ )} +
+ +
+ ); +} + +export function ReviewSweepView() { + const phase = useReviewSweepStore((state) => state.phase); + const order = useReviewSweepStore((state) => state.order); + const items = useReviewSweepStore((state) => state.items); + const truncatedCount = useReviewSweepStore((state) => state.truncatedCount); + const projects = useProjects(); + + // Projects are only unique per (environmentId, projectId) — ids can + // collide across connected environments. + const projectsByKey = useMemo(() => { + const byKey = new Map(); + for (const project of projects) { + byKey.set(scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), { + title: project.title, + workspaceRoot: project.workspaceRoot, + }); + } + return byKey; + }, [projects]); + + const visibleItems = useMemo( + () => + order + .map((key) => items[key]) + .filter((item): item is SweepItem => item !== undefined && !item.dismissed), + [items, order], + ); + + const buckets = useMemo(() => { + const grouped = new Map(); + for (const item of visibleItems) { + const bucket = classifySweepItem(item); + const group = grouped.get(bucket); + if (group) { + group.push(item); + } else { + grouped.set(bucket, [item]); + } + } + // Within a bucket, smallest diff first — clear the easy ones, build + // momentum. Unknown diff sizes sink to the bottom. + for (const group of grouped.values()) { + group.sort((a, b) => { + const sizeOf = (item: SweepItem) => { + const stats = item.result?.diffStats; + return stats ? stats.additions + stats.deletions : Number.MAX_SAFE_INTEGER; + }; + return sizeOf(a) - sizeOf(b); + }); + } + return BUCKET_ORDER.flatMap((bucket) => { + const group = grouped.get(bucket); + return group && group.length > 0 ? [[bucket, group] as const] : []; + }); + }, [visibleItems]); + + const reviewedCount = visibleItems.filter( + (item) => item.status === "done" || item.status === "error", + ).length; + const applicableCount = visibleItems.filter( + (item) => + (item.result?.suggestedTitle && !item.titleApplied) || + (item.result?.recommendSettle && !item.settleApplied), + ).length; + const running = phase === "running"; + const [applyingAll, setApplyingAll] = useState(false); + const [mergingAll, setMergingAll] = useState(false); + + return ( + +
+
+
+ Work review + {phase !== "idle" ? ( + + {running ? ( + <> + {reviewedCount} of {visibleItems.length} reviewed + + ) : ( + <>{visibleItems.length} threads reviewed + )} + + ) : null} + {phase !== "idle" ? ( +
+ + +
+ ) : null} +
+
+ +
+ {phase === "idle" ? ( + + ) : running ? ( + + + + + Reviewing your work — {reviewedCount} of {visibleItems.length} + + + Results appear all at once when every thread is reviewed, so you can triage in one + pass. Feel free to navigate away; the sweep keeps running. + + + + ) : visibleItems.length === 0 ? ( + + + All caught up + No unsettled threads to review right now. + + + ) : ( + +
+ {truncatedCount > 0 ? ( +

+ Reviewed the {visibleItems.length} most recent threads; {truncatedCount} more + were skipped this run. +

+ ) : null} + {buckets.map(([bucket, bucketItems]) => { + const meta = BUCKET_META[bucket]; + const BucketIcon = meta.icon; + const mergeableCount = + bucket === "mergeReady" + ? bucketItems.filter( + (item) => item.mergeStatus === "idle" || item.mergeStatus === "queued", + ).length + : 0; + return ( +
+
+

+ + {meta.title} + + · {bucketItems.length} + +

+ {meta.hint ? ( + + {meta.hint} + + ) : null} + {bucket === "mergeReady" && mergeableCount > 0 ? ( + + ) : null} +
+
+ {bucketItems.map((item) => { + const projectKey = scopedProjectKey( + scopeProjectRef(item.ref.environmentId, item.projectId), + ); + const project = projectsByKey.get(projectKey); + return ( + + ); + })} +
+
+ ); + })} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/reviewSweepStore.ts b/apps/web/src/reviewSweepStore.ts new file mode 100644 index 00000000000..52f754a1b66 --- /dev/null +++ b/apps/web/src/reviewSweepStore.ts @@ -0,0 +1,584 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + canSettle, + effectiveSettled, + threadLastActivityAt, +} from "@t3tools/client-runtime/state/thread-settled"; +import { toSortableTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type EnvironmentId, + type ProjectId, + type ReviewThreadSummaryResult, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { create } from "zustand"; + +import { stackedThreadToast, toastManager } from "./components/ui/toast"; +import { getClientSettings } from "./hooks/useSettings"; +import { newMessageId } from "~/lib/utils"; +import { appAtomRegistry } from "./rpc/atomRegistry"; +import { readThreadShell, readThreadRefs } from "./state/entities"; +import { reviewEnvironment } from "./state/review"; +import { environmentServerConfigsAtom } from "./state/server"; +import { threadEnvironment } from "./state/threads"; + +/** PR states mirrored from SidebarV2's per-row VCS subscriptions (keyed by + scoped thread key). The sidebar auto-settles merged/closed+idle threads + using this data; the sweep must see the same states or it will count + those threads as unsettled. Module-level because the sidebar is mounted + for the app's lifetime and owns the subscriptions. */ +let changeRequestStateByKey: ReadonlyMap = new Map(); + +export function publishSweepChangeRequestStates( + states: ReadonlyMap, +): void { + changeRequestStateByKey = states; + // Bump the store version so the pre-run summary recomputes its candidate + // count as PR states stream in from the sidebar rows. + useReviewSweepStore.getState().bumpCandidateVersion(); +} + +const SWEEP_CONCURRENCY = 3; +/** Soft cap so a giant backlog doesn't spawn an unbounded number of LLM + generations in one click. */ +export const SWEEP_MAX_THREADS = 50; + +export type SweepItemStatus = "pending" | "running" | "done" | "error"; + +/** Merge-queue lifecycle for merge-ready items. "conflicted" means the + merge failed and the thread's agent has been asked to resolve it. */ +export type SweepMergeStatus = "idle" | "queued" | "merging" | "merged" | "conflicted" | "failed"; + +export interface SweepItem { + readonly ref: ScopedThreadRef; + readonly projectId: ProjectId; + readonly threadTitle: string; + readonly branch: string | null; + /** Last activity (falls back to createdAt) for the age label. */ + readonly lastActivityAt: string; + readonly status: SweepItemStatus; + readonly result: ReviewThreadSummaryResult | null; + readonly errorMessage: string | null; + readonly titleApplied: boolean; + readonly settleApplied: boolean; + readonly dismissed: boolean; + readonly mergeStatus: SweepMergeStatus; + readonly mergeDetail: string | null; +} + +export type SweepPhase = "idle" | "running" | "complete"; + +interface ReviewSweepState { + phase: SweepPhase; + runId: number; + /** Scoped thread keys in review order. */ + order: ReadonlyArray; + items: Readonly>; + /** Count of candidates dropped by SWEEP_MAX_THREADS (0 = full coverage). */ + truncatedCount: number; + /** Bumped when sidebar-owned PR states change; the pre-run summary + subscribes so its candidate count tracks the sidebar's partition. */ + candidateVersion: number; + startRun: (input: { + runId: number; + items: ReadonlyArray; + truncatedCount: number; + }) => void; + patchItem: (key: string, patch: Partial) => void; + finishRun: (runId: number) => void; + bumpCandidateVersion: () => void; + reset: () => void; +} + +export const useReviewSweepStore = create((set) => ({ + phase: "idle", + runId: 0, + order: [], + items: {}, + truncatedCount: 0, + candidateVersion: 0, + startRun: ({ runId, items, truncatedCount }) => + set({ + phase: "running", + runId, + order: items.map((item) => scopedThreadKey(item.ref)), + items: Object.fromEntries(items.map((item) => [scopedThreadKey(item.ref), item])), + truncatedCount, + }), + patchItem: (key, patch) => + set((state) => { + const existing = state.items[key]; + if (!existing) return state; + return { items: { ...state.items, [key]: { ...existing, ...patch } } }; + }), + finishRun: (runId) => set((state) => (state.runId === runId ? { phase: "complete" } : state)), + bumpCandidateVersion: () => set((state) => ({ candidateVersion: state.candidateVersion + 1 })), + reset: () => set({ phase: "idle", order: [], items: {}, truncatedCount: 0 }), +})); + +function readServerCapabilities(environmentId: EnvironmentId) { + return appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment + .capabilities; +} + +/** The sweep-eligibility predicate, mirroring SidebarV2's active-card + partition. Exported so the pre-run confirmation screen counts exactly + the threads a run would review. */ +export function isSweepCandidate( + shell: EnvironmentThreadShell, + capabilities: { threadSettlement?: boolean; reviewSweep?: boolean } | undefined, + options: { readonly now: string; readonly autoSettleAfterDays: number | null }, +): boolean { + if (shell.archivedAt !== null) return false; + if (capabilities?.threadSettlement !== true || capabilities.reviewSweep !== true) return false; + // PR state comes from SidebarV2's per-row subscriptions via + // publishSweepChangeRequestStates, so merged-PR+idle threads classify as + // settled here exactly like they do in the sidebar. Rows that haven't + // reported yet fall back to null (treated as unsettled) — same as a + // freshly mounted sidebar. + const changeRequestState = + changeRequestStateByKey.get(scopedThreadKey(scopeThreadRef(shell.environmentId, shell.id))) ?? + null; + return !effectiveSettled(shell, { + now: options.now, + autoSettleAfterDays: options.autoSettleAfterDays, + changeRequestState, + }); +} + +/** All unsettled, unarchived threads across every connected environment that + supports the sweep, most recently active first — the exact order the + SWEEP_MAX_THREADS cap is applied in. */ +export function collectSweepCandidates(): EnvironmentThreadShell[] { + const now = new Date().toISOString(); + const autoSettleAfterDays = getClientSettings().sidebarAutoSettleAfterDays; + const candidates: EnvironmentThreadShell[] = []; + for (const ref of readThreadRefs()) { + const shell = readThreadShell(ref); + if (!shell) continue; + const capabilities = readServerCapabilities(shell.environmentId); + if (!isSweepCandidate(shell, capabilities, { now, autoSettleAfterDays })) continue; + candidates.push(shell); + } + return sortSweepCandidates(candidates); +} + +/** Most recently active first, so the SWEEP_MAX_THREADS cap drops the + longest-idle threads rather than the oldest-created ones. Exported so + the pre-run summary previews the exact capped selection. */ +export function sortSweepCandidates( + candidates: ReadonlyArray, +): EnvironmentThreadShell[] { + const activityMs = (shell: EnvironmentThreadShell): number => + toSortableTimestamp(threadLastActivityAt(shell) ?? undefined) ?? + toSortableTimestamp(shell.createdAt) ?? + 0; + return [...candidates].sort((a, b) => activityMs(b) - activityMs(a)); +} + +async function reviewOne(runId: number, item: SweepItem): Promise { + const store = useReviewSweepStore.getState(); + if (store.runId !== runId) return; + const key = scopedThreadKey(item.ref); + store.patchItem(key, { status: "running" }); + + const shell = readThreadShell(item.ref); + const canSettleNow = shell !== null && canSettle(shell, { now: new Date().toISOString() }); + const result = await runAtomCommand( + appAtomRegistry, + reviewEnvironment.summarizeThread, + { + environmentId: item.ref.environmentId, + input: { threadId: item.ref.threadId, canSettleNow }, + }, + { reportFailure: false, reportDefect: false }, + ); + + const current = useReviewSweepStore.getState(); + if (current.runId !== runId) return; + if (result._tag === "Success") { + current.patchItem(key, { status: "done", result: result.value, errorMessage: null }); + } else { + const error = squashAtomCommandFailure(result); + current.patchItem(key, { + status: "error", + errorMessage: error instanceof Error ? error.message : "Review failed.", + }); + } +} + +/** Navigation callback installed by the app shell so store-level toasts can + deep-link to /review-sweep without importing the router. */ +let navigateToSweepResults: (() => void) | null = null; +export function setSweepResultsNavigator(navigate: (() => void) | null): void { + navigateToSweepResults = navigate; +} + +function isViewingSweepResults(): boolean { + return window.location.pathname === "/review-sweep"; +} + +async function runPool(runId: number, items: ReadonlyArray): Promise { + let next = 0; + const workers = Array.from({ length: Math.min(SWEEP_CONCURRENCY, items.length) }, async () => { + while (next < items.length) { + if (useReviewSweepStore.getState().runId !== runId) return; + const item = items[next]; + next += 1; + if (item) await reviewOne(runId, item); + } + }); + await Promise.all(workers); + const store = useReviewSweepStore.getState(); + if (store.runId !== runId) return; + store.finishRun(runId); + // The sweep runs in the background; only toast when the user isn't + // already staring at the results page. + if (!isViewingSweepResults()) { + const actionable = Object.values(store.items).filter( + (item) => + !item.dismissed && + ((item.result?.suggestedTitle && !item.titleApplied) || + (item.result?.recommendSettle && !item.settleApplied) || + item.result?.prStatus?.mergeReady === true), + ).length; + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Work review finished", + description: + actionable > 0 + ? `${actionable} ${actionable === 1 ? "thread has" : "threads have"} recommended actions.` + : "No actions recommended — you're caught up.", + actionVariant: "outline", + actionProps: { + children: "View results", + onClick: () => navigateToSweepResults?.(), + }, + }), + ); + } +} + +/** Kick off a sweep over every unsettled thread. Runs outside React so it + survives navigation; results land in the store as they arrive. */ +export function startReviewSweep(): void { + const runId = useReviewSweepStore.getState().runId + 1; + const candidates = collectSweepCandidates(); + const reviewed = candidates.slice(0, SWEEP_MAX_THREADS); + const items = reviewed.map( + (shell): SweepItem => ({ + ref: scopeThreadRef(shell.environmentId, shell.id), + projectId: shell.projectId, + threadTitle: shell.title, + branch: shell.branch, + lastActivityAt: threadLastActivityAt(shell) ?? shell.createdAt, + status: "pending", + result: null, + errorMessage: null, + titleApplied: false, + settleApplied: false, + dismissed: false, + mergeStatus: "idle", + mergeDetail: null, + }), + ); + useReviewSweepStore.getState().startRun({ + runId, + items, + truncatedCount: candidates.length - reviewed.length, + }); + if (items.length === 0) { + useReviewSweepStore.getState().finishRun(runId); + return; + } + void runPool(runId, items); +} + +export function retrySweepItem(key: string): void { + const store = useReviewSweepStore.getState(); + const item = store.items[key]; + if (!item || item.status === "running") return; + void reviewOne(store.runId, item); +} + +export async function applySweepTitle(key: string): Promise { + const store = useReviewSweepStore.getState(); + const runId = store.runId; + const item = store.items[key]; + const suggestedTitle = item?.result?.suggestedTitle; + if (!item || !suggestedTitle || item.titleApplied) return false; + const result = await runAtomCommand( + appAtomRegistry, + threadEnvironment.updateMetadata, + { + environmentId: item.ref.environmentId, + input: { threadId: item.ref.threadId, title: suggestedTitle }, + }, + { reportFailure: false }, + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return false; + } + // The rename itself succeeded either way, but a Re-run mid-flight replaces + // the item set with fresh reviews — don't stamp stale applied-state onto + // the new run's card. + const current = useReviewSweepStore.getState(); + if (current.runId === runId) { + current.patchItem(key, { titleApplied: true, threadTitle: suggestedTitle }); + } + return true; +} + +export async function applySweepSettle(key: string): Promise { + const store = useReviewSweepStore.getState(); + const runId = store.runId; + const item = store.items[key]; + if (!item || !item.result?.recommendSettle || item.settleApplied) return false; + // Re-check against the live shell: the thread may have gone active since + // the review ran, and settling live work would hide it. + const shell = readThreadShell(item.ref); + if (!shell || !canSettle(shell, { now: new Date().toISOString() })) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Thread is active again", + description: "This thread picked up new activity since the review, so it stays active.", + }), + ); + // The recommendation is now known-stale — withdraw it so the card stops + // advertising a settle the live guard just refused. + store.patchItem(key, { + result: { ...item.result, recommendSettle: false, settleReason: null }, + }); + return false; + } + const result = await runAtomCommand( + appAtomRegistry, + threadEnvironment.settle, + { + environmentId: item.ref.environmentId, + input: { threadId: item.ref.threadId }, + }, + { reportFailure: false }, + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return false; + } + // Same staleness guard as applySweepTitle: never patch a newer run's item. + const current = useReviewSweepStore.getState(); + if (current.runId === runId) { + current.patchItem(key, { settleApplied: true }); + } + return true; +} + +/** Sequentially apply every un-dismissed recommendation: titles first, then + settles, tolerating per-item failures (each failure already toasts). + Items are re-read from the store on every step so dismissals (and + re-runs) mid-apply are respected. */ +export async function applyAllSweepRecommendations(): Promise { + const { order, runId } = useReviewSweepStore.getState(); + for (const key of order) { + const current = useReviewSweepStore.getState(); + if (current.runId !== runId) return; + const item = current.items[key]; + if (!item || item.dismissed) continue; + if (item.result?.suggestedTitle && !item.titleApplied) { + await applySweepTitle(key); + } + } + for (const key of order) { + const current = useReviewSweepStore.getState(); + if (current.runId !== runId) return; + const item = current.items[key]; + if (!item || item.dismissed) continue; + if (item.result?.recommendSettle && !item.settleApplied) { + await applySweepSettle(key); + } + } +} + +export function dismissSweepItem(key: string): void { + useReviewSweepStore.getState().patchItem(key, { dismissed: true }); +} + +// --------------------------------------------------------------------------- +// Merge queue +// --------------------------------------------------------------------------- + +/** Message sent to a thread's agent when its PR stopped merging cleanly + because earlier queue entries landed first. */ +function conflictHandoffMessage(prNumber: number): string { + return [ + `PR #${prNumber} no longer merges cleanly — other pull requests were merged into the base branch ahead of it.`, + "Please rebase this branch onto the latest base branch, resolve any merge conflicts, verify the build still passes, and push the updated branch.", + ].join(" "); +} + +function isMergeQueueCandidate(item: SweepItem): boolean { + return ( + !item.dismissed && + item.result?.prStatus?.mergeReady === true && + (item.mergeStatus === "idle" || item.mergeStatus === "queued") + ); +} + +let mergeQueueActive = false; + +/** Merge one item's PR and settle its thread on success. On conflict, mark + the card and hand the rebase to the thread's agent. Returns the outcome + for queue accounting. */ +async function mergeOne( + runId: number, + key: string, +): Promise<"merged" | "conflict" | "skipped" | "failed"> { + const store = useReviewSweepStore.getState(); + if (store.runId !== runId) return "skipped"; + const item = store.items[key]; + const prNumber = item?.result?.prStatus?.number; + if (!item || prNumber === undefined) return "skipped"; + store.patchItem(key, { mergeStatus: "merging" }); + + const result = await runAtomCommand( + appAtomRegistry, + reviewEnvironment.mergePullRequest, + { + environmentId: item.ref.environmentId, + input: { threadId: item.ref.threadId, pullRequestNumber: prNumber }, + }, + { reportFailure: false, reportDefect: false }, + ); + + const after = useReviewSweepStore.getState(); + if (after.runId !== runId) return "skipped"; + + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + after.patchItem(key, { + mergeStatus: "failed", + mergeDetail: error instanceof Error ? error.message : "Merge failed.", + }); + return "failed"; + } + + const outcome = result.value.outcome; + if (outcome === "merged" || outcome === "already-closed") { + after.patchItem(key, { mergeStatus: "merged", mergeDetail: result.value.detail }); + // Merged PRs are concluded work: settle right away so the sidebar + // reflects the merge without waiting for auto-settle. + const settleResult = await runAtomCommand( + appAtomRegistry, + threadEnvironment.settle, + { + environmentId: item.ref.environmentId, + input: { threadId: item.ref.threadId }, + }, + { reportFailure: false }, + ); + if (settleResult._tag === "Success") { + useReviewSweepStore.getState().patchItem(key, { settleApplied: true }); + } + return "merged"; + } + + if (outcome === "conflict") { + after.patchItem(key, { mergeStatus: "conflicted", mergeDetail: result.value.detail }); + // Hand the conflict to the thread's own agent: it has the branch + // checked out and the full context to rebase and resolve. + await runAtomCommand( + appAtomRegistry, + threadEnvironment.startTurn, + { + environmentId: item.ref.environmentId, + input: { + threadId: item.ref.threadId, + message: { + messageId: newMessageId(), + role: "user" as const, + text: conflictHandoffMessage(prNumber), + attachments: [], + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: new Date().toISOString(), + }, + }, + { reportFailure: false }, + ); + return "conflict"; + } + + after.patchItem(key, { mergeStatus: "failed", mergeDetail: result.value.detail }); + return "failed"; +} + +/** Merge a single card's PR (per-item button). */ +export async function mergeSweepItem(key: string): Promise { + const { runId } = useReviewSweepStore.getState(); + await mergeOne(runId, key); +} + +/** Merge every merge-ready PR SEQUENTIALLY — one at a time, so each merge + sees the base branch the previous one produced and the server re-check + catches freshly introduced conflicts. Conflicts don't stop the queue: + the item is marked conflicted and its thread's agent is asked to rebase + and resolve; re-run the sweep once it pushes to pick the PR back up. */ +export async function runMergeQueue(): Promise { + if (mergeQueueActive) return; + mergeQueueActive = true; + try { + const { order, items, runId } = useReviewSweepStore.getState(); + const queueKeys = order.filter((key) => { + const item = items[key]; + return item !== undefined && isMergeQueueCandidate(item); + }); + for (const key of queueKeys) { + useReviewSweepStore.getState().patchItem(key, { mergeStatus: "queued" }); + } + + let mergedCount = 0; + let conflictCount = 0; + for (const key of queueKeys) { + const outcome = await mergeOne(runId, key); + if (outcome === "merged") mergedCount += 1; + if (outcome === "conflict") conflictCount += 1; + if (useReviewSweepStore.getState().runId !== runId) return; + } + + if (queueKeys.length > 0) { + toastManager.add( + stackedThreadToast({ + type: conflictCount > 0 ? "warning" : "success", + title: `Merge queue finished: ${mergedCount} of ${queueKeys.length} merged`, + description: + conflictCount > 0 + ? `${conflictCount} ${conflictCount === 1 ? "PR hit a conflict and was" : "PRs hit conflicts and were"} handed to ${conflictCount === 1 ? "its" : "their"} thread's agent to rebase.` + : undefined, + }), + ); + } + } finally { + mergeQueueActive = false; + } +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 563d1b43755..58ed61cd07c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' +import { Route as ReviewSweepRouteImport } from './routes/review-sweep' import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' @@ -31,6 +32,11 @@ const SettingsRoute = SettingsRouteImport.update({ path: '/settings', getParentRoute: () => rootRouteImport, } as any) +const ReviewSweepRoute = ReviewSweepRouteImport.update({ + id: '/review-sweep', + path: '/review-sweep', + getParentRoute: () => rootRouteImport, +} as any) const PairRoute = PairRouteImport.update({ id: '/pair', path: '/pair', @@ -111,6 +117,7 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/review-sweep': typeof ReviewSweepRoute '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute @@ -127,6 +134,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/review-sweep': typeof ReviewSweepRoute '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute @@ -146,6 +154,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/review-sweep': typeof ReviewSweepRoute '/settings': typeof SettingsRouteWithChildren '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute @@ -166,6 +175,7 @@ export interface FileRouteTypes { | '/' | '/connect' | '/pair' + | '/review-sweep' | '/settings' | '/connect/callback' | '/settings/archived' @@ -182,6 +192,7 @@ export interface FileRouteTypes { to: | '/connect' | '/pair' + | '/review-sweep' | '/settings' | '/connect/callback' | '/settings/archived' @@ -200,6 +211,7 @@ export interface FileRouteTypes { | '/_chat' | '/connect' | '/pair' + | '/review-sweep' | '/settings' | '/connect_/callback' | '/settings/archived' @@ -219,6 +231,7 @@ export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute + ReviewSweepRoute: typeof ReviewSweepRoute SettingsRoute: typeof SettingsRouteWithChildren ConnectCallbackRoute: typeof ConnectCallbackRoute } @@ -232,6 +245,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/review-sweep': { + id: '/review-sweep' + path: '/review-sweep' + fullPath: '/review-sweep' + preLoaderRoute: typeof ReviewSweepRouteImport + parentRoute: typeof rootRouteImport + } '/pair': { id: '/pair' path: '/pair' @@ -384,6 +404,7 @@ const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, ConnectRoute: ConnectRoute, PairRoute: PairRoute, + ReviewSweepRoute: ReviewSweepRoute, SettingsRoute: SettingsRouteWithChildren, ConnectCallbackRoute: ConnectCallbackRoute, } diff --git a/apps/web/src/routes/review-sweep.tsx b/apps/web/src/routes/review-sweep.tsx new file mode 100644 index 00000000000..219bc74e98b --- /dev/null +++ b/apps/web/src/routes/review-sweep.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { ReviewSweepView } from "../components/reviewSweep/ReviewSweepView"; + +export const Route = createFileRoute("/review-sweep")({ + beforeLoad: async ({ context }) => { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: ReviewSweepView, +}); diff --git a/packages/client-runtime/src/state/review.ts b/packages/client-runtime/src/state/review.ts index 0d78d6edd9f..d834b190b67 100644 --- a/packages/client-runtime/src/state/review.ts +++ b/packages/client-runtime/src/state/review.ts @@ -1,7 +1,7 @@ import { WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; export function createReviewEnvironmentAtoms( @@ -13,5 +13,13 @@ export function createReviewEnvironmentAtoms( tag: WS_METHODS.reviewGetDiffPreview, staleTimeMs: 5_000, }), + summarizeThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:review:summarize-thread", + tag: WS_METHODS.reviewSummarizeThread, + }), + mergePullRequest: createEnvironmentRpcCommand(runtime, { + label: "environment-data:review:merge-pull-request", + tag: WS_METHODS.reviewMergePullRequest, + }), }; } diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7f4b6c16541..b92b6a6ce2f 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -47,6 +47,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Server understands the review.summarizeThread RPC used by the sidebar + work-review sweep. Absent on older servers, so clients skip their + threads instead of sending an unknown method. */ + reviewSweep: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index a6b879a0c7f..a0a11c126a9 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; -import { GitCommandError } from "./git.ts"; +import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { GitCommandError, TextGenerationError } from "./git.ts"; import { VcsError } from "./vcs.ts"; export const ReviewDiffPreviewInput = Schema.Struct({ @@ -34,3 +34,106 @@ export type ReviewDiffPreviewResult = typeof ReviewDiffPreviewResult.Type; export const ReviewDiffPreviewError = Schema.Union([VcsError, GitCommandError]); export type ReviewDiffPreviewError = typeof ReviewDiffPreviewError.Type; + +export const ReviewThreadSummaryInput = Schema.Struct({ + threadId: ThreadId, + /** Whether the thread is currently eligible to settle (no running session, + pending approvals, or pending user input). The server never recommends + settling when this is false, regardless of what the model says. */ + canSettleNow: Schema.Boolean, +}); +export type ReviewThreadSummaryInput = typeof ReviewThreadSummaryInput.Type; + +/** Size of the thread's latest ready checkpoint diff — a cheap effort proxy + for the review UI ("how big is this thread's change"). */ +export const ReviewThreadDiffStats = Schema.Struct({ + files: Schema.Int, + additions: Schema.Int, + deletions: Schema.Int, +}); +export type ReviewThreadDiffStats = typeof ReviewThreadDiffStats.Type; + +/** Live pull-request context gathered during the review: state, review + verdict, CI, and whether the PR is mergeable as-is. */ +export const ReviewThreadPrStatus = Schema.Struct({ + number: Schema.Int, + url: TrimmedNonEmptyString, + state: Schema.Literals(["open", "closed", "merged"]), + /** GitHub reviewDecision: APPROVED / CHANGES_REQUESTED / REVIEW_REQUIRED / + empty (no required reviews). */ + reviewDecision: Schema.NullOr(TrimmedNonEmptyString), + /** Rolled-up CI outcome for the head commit. */ + checksPassing: Schema.NullOr(Schema.Boolean), + /** GitHub mergeable: whether the branch applies cleanly onto base. */ + mergeable: Schema.NullOr(Schema.Boolean), + /** Open + clean merge + CI green + not blocked by requested changes. */ + mergeReady: Schema.Boolean, + recentCommentCount: Schema.Int, +}); +export type ReviewThreadPrStatus = typeof ReviewThreadPrStatus.Type; + +export const ReviewThreadSummaryResult = Schema.Struct({ + threadId: ThreadId, + summary: TrimmedNonEmptyString, + /** One imperative sentence: the user's single next action. Optional for + pre-nextStep servers. */ + nextStep: Schema.optionalKey(TrimmedNonEmptyString), + /** Null when the current title is still accurate. */ + suggestedTitle: Schema.NullOr(TrimmedNonEmptyString), + recommendSettle: Schema.Boolean, + settleReason: Schema.NullOr(TrimmedNonEmptyString), + /** Absent when the thread has no ready checkpoint (or pre-diff servers). */ + diffStats: Schema.optionalKey(ReviewThreadDiffStats), + /** Absent when the thread has no PR, the lookup failed, or pre-PR servers. */ + prStatus: Schema.optionalKey(ReviewThreadPrStatus), +}); +export type ReviewThreadSummaryResult = typeof ReviewThreadSummaryResult.Type; + +export class ReviewThreadNotFoundError extends Schema.TaggedErrorClass()( + "ReviewThreadNotFoundError", + { threadId: ThreadId }, +) {} + +export const ReviewThreadSummaryError = Schema.Union([ + TextGenerationError, + ReviewThreadNotFoundError, +]); +export type ReviewThreadSummaryError = typeof ReviewThreadSummaryError.Type; + +export const ReviewMergePullRequestInput = Schema.Struct({ + threadId: ThreadId, + /** The PR number the client saw at review time; the server re-validates + merge-readiness against live GitHub state before merging. */ + pullRequestNumber: Schema.Int, +}); +export type ReviewMergePullRequestInput = typeof ReviewMergePullRequestInput.Type; + +export const ReviewMergePullRequestResult = Schema.Struct({ + threadId: ThreadId, + outcome: Schema.Literals([ + "merged", + /** Branch no longer applies cleanly (e.g. an earlier queue merge landed + first). The client hands the conflict to the thread's agent. */ + "conflict", + /** PR already merged or closed since the review ran. */ + "already-closed", + /** No longer merge-ready (CI regressed, review dismissed, ...). */ + "not-ready", + ]), + detail: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ReviewMergePullRequestResult = typeof ReviewMergePullRequestResult.Type; + +export class ReviewMergeError extends Schema.TaggedErrorClass()( + "ReviewMergeError", + { + threadId: ThreadId, + detail: TrimmedNonEmptyString, + }, +) {} + +export const ReviewMergePullRequestError = Schema.Union([ + ReviewThreadNotFoundError, + ReviewMergeError, +]); +export type ReviewMergePullRequestError = typeof ReviewMergePullRequestError.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef2..cfb6645d0d6 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -43,6 +43,12 @@ import { ReviewDiffPreviewError, ReviewDiffPreviewInput, ReviewDiffPreviewResult, + ReviewMergePullRequestError, + ReviewMergePullRequestInput, + ReviewMergePullRequestResult, + ReviewThreadSummaryError, + ReviewThreadSummaryInput, + ReviewThreadSummaryResult, } from "./review.ts"; import { KeybindingsConfigError } from "./keybindings.ts"; import { @@ -181,6 +187,8 @@ export const WS_METHODS = { // Review methods reviewGetDiffPreview: "review.getDiffPreview", + reviewSummarizeThread: "review.summarizeThread", + reviewMergePullRequest: "review.mergePullRequest", // Terminal methods terminalOpen: "terminal.open", @@ -495,6 +503,24 @@ export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPrevie error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), }); +/** One-shot agentic review of a single thread for the sidebar work-review + sweep: summary, optional corrected title, and a settle recommendation. */ +export const WsReviewSummarizeThreadRpc = Rpc.make(WS_METHODS.reviewSummarizeThread, { + payload: ReviewThreadSummaryInput, + success: ReviewThreadSummaryResult, + error: Schema.Union([ReviewThreadSummaryError, EnvironmentAuthorizationError]), +}); + +/** Merge a review-sweep thread's PR after re-validating merge readiness + against live GitHub state. Non-exceptional failures (conflict, already + closed, no longer ready) are outcomes, not errors, so the client's merge + queue can branch on them. */ +export const WsReviewMergePullRequestRpc = Rpc.make(WS_METHODS.reviewMergePullRequest, { + payload: ReviewMergePullRequestInput, + success: ReviewMergePullRequestResult, + error: Schema.Union([ReviewMergePullRequestError, EnvironmentAuthorizationError]), +}); + export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { payload: TerminalOpenInput, success: TerminalSessionSnapshot, @@ -738,6 +764,8 @@ export const WsRpcGroup = RpcGroup.make( WsVcsSwitchRefRpc, WsVcsInitRpc, WsReviewGetDiffPreviewRpc, + WsReviewSummarizeThreadRpc, + WsReviewMergePullRequestRpc, WsTerminalOpenRpc, WsTerminalAttachRpc, WsTerminalWriteRpc,