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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ jobs:
SMOKE_TIMEOUT_MS: '300000'
run: npm run smoke:app

- name: Review suite (Linux, via virtual display)
if: runner.os == 'Linux'
env:
ELECTRON_DISABLE_SANDBOX: '1'
run: xvfb-run --auto-servernum npm run smoke:review

- name: Review suite
if: runner.os != 'Linux'
run: npm run smoke:review

- name: Canvas interactions (real Chromium input and clipboard menus)
if: runner.os == 'Linux'
env:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"smoke:cliproxy": "esbuild test/cliproxy.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cliproxy.cjs && electron test/.out/cliproxy.cjs",
"worktree:setup": "npm ci --prefer-offline --no-audit --no-fund && electron-builder install-app-deps",
"smoke:store": "node test/store-guard.mjs",
"smoke:review": "esbuild test/review.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/review.cjs && electron test/.out/review.cjs && esbuild test/session-review.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/session-review.cjs && electron test/.out/session-review.cjs",
"smoke:cookies": "esbuild test/cookies.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cookies.cjs && electron test/.out/cookies.cjs",
"smoke:i18n": "esbuild test/i18n.ts --bundle --platform=node --format=cjs --outfile=test/.out/i18n.cjs && node test/.out/i18n.cjs",
"i18n:translate": "node script/i18n-translate.mjs",
Expand Down
23 changes: 23 additions & 0 deletions src/main/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ const REPAIR_SCHEMA_SQL = /* sql */ `
hidden_at INTEGER NOT NULL,
PRIMARY KEY (provider_id, model)
);
CREATE TABLE IF NOT EXISTS session_review_baselines (
session_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
repo_key TEXT NOT NULL,
repo_root TEXT NOT NULL,
baseline_tree TEXT NOT NULL,
baseline_ref TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, repo_key)
);
CREATE TABLE IF NOT EXISTS projects (
path TEXT PRIMARY KEY,
sort_order INTEGER NOT NULL,
Expand Down Expand Up @@ -493,6 +502,20 @@ export const MIGRATIONS: Migration[] = [
hidden_at INTEGER NOT NULL,
PRIMARY KEY (provider_id, model)
);
`,

// ---- v24: durable per-session review baselines ----
// One Git tree per repository records the workspace before the first turn.
/* sql */ `
CREATE TABLE session_review_baselines (
session_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
repo_key TEXT NOT NULL,
repo_root TEXT NOT NULL,
baseline_tree TEXT NOT NULL,
baseline_ref TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, repo_key)
);
`
]

Expand Down
68 changes: 68 additions & 0 deletions src/main/db/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@ interface ProviderRow {
has_credential: number
}

export interface SessionReviewBaseline {
sessionId: string
repoKey: string
repoRoot: string
baselineTree: string
baselineRef: string
createdAt: number
}

interface SessionReviewBaselineRow {
session_id: string
repo_key: string
repo_root: string
baseline_tree: string
baseline_ref: string
created_at: number
}

interface ChatRow {
id: string
title: string
Expand Down Expand Up @@ -1684,6 +1702,56 @@ export function recordActivityTurn(day: string, turns = 1): void {
.run(day, Math.floor(turns))
}

export function getSessionReviewBaseline(
sessionId: string,
repoKey: string
): SessionReviewBaseline | undefined {
const row = getDb()
.prepare('SELECT * FROM session_review_baselines WHERE session_id = ? AND repo_key = ?')
.get(sessionId, repoKey) as SessionReviewBaselineRow | undefined
return row ? sessionReviewBaseline(row) : undefined
}

export function listSessionReviewBaselines(sessionId: string): SessionReviewBaseline[] {
const rows = getDb()
.prepare('SELECT * FROM session_review_baselines WHERE session_id = ? ORDER BY repo_key')
.all(sessionId) as SessionReviewBaselineRow[]
return rows.map(sessionReviewBaseline)
}

export function addSessionReviewBaseline(
input: Omit<SessionReviewBaseline, 'createdAt'>
): SessionReviewBaseline {
const createdAt = Date.now()
getDb()
.prepare(
`INSERT INTO session_review_baselines
(session_id, repo_key, repo_root, baseline_tree, baseline_ref, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, repo_key) DO NOTHING`
)
.run(
input.sessionId,
input.repoKey,
input.repoRoot,
input.baselineTree,
input.baselineRef,
createdAt
)
return getSessionReviewBaseline(input.sessionId, input.repoKey) ?? { ...input, createdAt }
}

function sessionReviewBaseline(row: SessionReviewBaselineRow): SessionReviewBaseline {
return {
sessionId: row.session_id,
repoKey: row.repo_key,
repoRoot: row.repo_root,
baselineTree: row.baseline_tree,
baselineRef: row.baseline_ref,
createdAt: row.created_at
}
}

/** Per-day turn counts from `fromDay` (inclusive, local YYYY-MM-DD) onward. */
export function listActivityDays(fromDay: string): Map<string, number> {
const rows = getDb()
Expand Down
15 changes: 15 additions & 0 deletions src/main/harness/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,19 @@ function gatherMcpRecords(cwd: string): McpServerRecord[] {

/** OpenAI function schemas for the workspace/browser tools (the base toolset). */
const BASE_SCHEMAS = [
fn(
'code_review',
'Show the active Git diff for the workspace. Unstaged includes untracked text files. Use this when the user asks you to review changes.',
{
scope: {
type: 'string',
enum: ['unstaged', 'staged', 'branch', 'commit'],
description: 'Review scope (default "unstaged").'
},
commit: str('Commit or ref. Required when scope is "commit".')
},
[]
),
fn(
'read',
'Read a file from the workspace.',
Expand Down Expand Up @@ -2148,6 +2161,8 @@ function toolTitle(name: string, input: Record<string, unknown>): string {
return s(input.title) || s(input.name) || 'session metadata'
case 'skill':
return s(input.name)
case 'code_review':
return s(input.commit) || s(input.scope) || 'unstaged'
default:
return isMcpTool(name) ? mcpToolTitle(name) : ''
}
Expand Down
138 changes: 138 additions & 0 deletions src/main/harness/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import {
normalizeFetchUrl
} from '../../shared/web'
import * as browser from '../services/browser'
import * as gitService from '../services/git'
import * as lsp from '../services/lsp'
import * as repo from '../db/repo'
import { discoverRepos } from '../services/workspace'
import { isManagedToolOutputPath } from '../services/tool-output-store'
import { renderDiagnosticsBlock } from '../../shared/lsp'
import {
Expand Down Expand Up @@ -173,6 +175,7 @@ interface BgProc {
}
const bgProcs = new Map<string, BgProc>()
let bgCounter = 0
const MAX_REVIEW_PATCH = 50_000

export async function runTool(
name: string,
Expand All @@ -194,6 +197,8 @@ export async function runTool(
return runBashOutput(str(input.id ?? input.process), owningSessionId(ctx))
case 'bash_kill':
return runBashKill(str(input.id ?? input.process), owningSessionId(ctx))
case 'code_review':
return await runCodeReview(str(input.scope), str(input.commit), ctx)
case 'read':
return await runRead(str(input.path ?? input.file), ctx.cwd)
case 'write':
Expand Down Expand Up @@ -301,6 +306,139 @@ export async function runTool(
}
}

async function runCodeReview(scope: string, commit: string, ctx: ToolContext): Promise<ToolResult> {
const resolvedScope = scope || 'unstaged'
if (!['unstaged', 'staged', 'branch', 'commit'].includes(resolvedScope)) {
return { ok: false, output: 'Invalid review scope.' }
}
if (resolvedScope === 'commit' && !commit) {
return { ok: false, output: 'Commit scope requires a commit.' }
}
if (!(await gitService.isGitAvailable())) {
return { ok: false, output: 'Git is not available in this workspace.' }
}

return untilAborted(ctx.signal, async () => {
const typedScope = resolvedScope as import('../../shared/api').GitReviewScope
const targets = resolveCodeReviewRepos(ctx)
if (!targets.length) return { ok: false, output: 'Git is not available in this workspace.' }

const multi = targets.length > 1
const sections: string[] = []
let validRanges = 0
for (const target of targets) {
const heading = multi ? `## Repository: ${target.name}\n` : ''
const root = target.cwd ? await gitService.repoRoot(target.cwd) : null
if (!root) {
sections.push(`${heading}Error: repository is unavailable.`)
continue
}
const revs = await gitService.revsForScope(root, typedScope, commit || undefined)
if (!revs) {
const detail =
typedScope === 'commit'
? `commit ${commit} does not exist in this repository.`
: 'no valid commit range found for this scope.'
sections.push(`${heading}Error: ${detail}`)
continue
}
const r = await gitService.git(
[
'diff',
'--patch',
'--binary',
'--find-renames',
'--find-copies',
...gitService.diffRange(revs)
],
root
)
if (!r.ok) {
sections.push(`${heading}Error: failed to generate diff.`)
continue
}
validRanges++

let output = r.stdout
if (typedScope === 'unstaged') {
const untracked = (await gitService.reviewFiles(root, 'unstaged')).filter(
(file) => file.status === 'untracked'
)
for (const file of untracked) {
const diff = await gitService.reviewDiff(root, 'unstaged', file.path)
if (!diff || diff.binary) {
output += `\nUntracked binary or large file: ${file.path}\n`
continue
}
const body = diff.after
.split('\n')
.map((line) => `+${line}`)
.join('\n')
output += `\ndiff --git a/${file.path} b/${file.path}\nnew file mode 100644\n--- /dev/null\n+++ b/${file.path}\n@@ -0,0 +1,${file.additions} @@\n${body}\n`
}
}
sections.push(`${heading}${output.trim() ? output : 'No changes found.'}`)
}

if (typedScope === 'commit' && validRanges === 0) {
return {
ok: false,
output: `Commit ${commit} was not found in any repository.\n\n${sections.join('\n\n')}`
}
}
const output = sections.join('\n\n')
return {
ok: true,
output:
output.length > MAX_REVIEW_PATCH
? `${output.slice(0, MAX_REVIEW_PATCH)}\n... (diff truncated due to length)`
: output
}
})
}

interface CodeReviewRepo {
name: string
cwd: string
}

interface CodeReviewOwner {
repos?: { name: string; worktreePath: string }[] | null
workspacePath?: string | null
}

/** Pure selection half, exposed so multi-repo routing can be unit tested. */
export function codeReviewReposForOwner(
owner: CodeReviewOwner | undefined,
cwd: string,
discover: typeof discoverRepos = discoverRepos
): CodeReviewRepo[] {
if (owner?.repos?.length) {
return owner.repos.map((link) => ({ name: link.name, cwd: link.worktreePath }))
}
if (owner?.workspacePath) {
const discovered = discover(owner.workspacePath)
if (discovered.layout === 'multi') {
return discovered.roots.map((repoCwd) => ({ name: path.basename(repoCwd), cwd: repoCwd }))
}
}
return cwd ? [{ name: path.basename(cwd), cwd }] : []
}

/** Resolve the repositories owned by a tool's root session without IPC. */
export function resolveCodeReviewRepos(
ctx: Pick<ToolContext, 'cwd' | 'sessionId'>
): CodeReviewRepo[] {
if (!ctx.sessionId) return codeReviewReposForOwner(undefined, ctx.cwd)
try {
const root = repo.getChat(repo.rootSessionId(ctx.sessionId))
return codeReviewReposForOwner(root, ctx.cwd)
} catch {
// Tests and keyless callers may not have initialized the database.
}
return codeReviewReposForOwner(undefined, ctx.cwd)
}

/** Whether a thrown value is an abort (ours, or a fetch/DOM AbortError). */
function isAbort(e: unknown): boolean {
return e instanceof Error && e.name === 'AbortError'
Expand Down
Loading
Loading