diff --git a/.ade/ade.db.pre-crsqlite-w1.bak b/.ade/ade.db.pre-crsqlite-w1.bak new file mode 100644 index 000000000..02d872100 Binary files /dev/null and b/.ade/ade.db.pre-crsqlite-w1.bak differ diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index a0cf85777..4b885bb95 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -46,6 +46,8 @@ import fs from "node:fs"; import net from "node:net"; import { createMcpRequestHandler } from "../../../mcp-server/src/mcpServer"; import { createEventBuffer, type AdeMcpRuntime, type AdeMcpPaths } from "../../../mcp-server/src/bootstrap"; +import { startJsonRpcServer } from "../../../mcp-server/src/jsonrpc"; +import type { JsonRpcTransport } from "../../../mcp-server/src/transport"; import { createKeybindingsService } from "./services/keybindings/keybindingsService"; import { createAgentToolsService } from "./services/agentTools/agentToolsService"; import { createDevToolsService } from "./services/devTools/devToolsService"; @@ -601,6 +603,7 @@ app.whenReady().then(async () => { const normalizeProjectRoot = (projectRoot: string) => path.resolve(projectRoot); const projectContexts = new Map(); const closeContextPromises = new Map>(); + const mcpSocketCleanupByRoot = new Map void>(); let activeProjectRoot: string | null = null; let dormantContext!: AppContext; @@ -1019,6 +1022,16 @@ app.whenReady().then(async () => { const onTrackedSessionEnded = ({ laneId, sessionId, exitCode }: { laneId: string; sessionId: string; exitCode: number | null }) => { jobEngine?.onSessionEnded({ laneId, sessionId }); automationService?.onSessionEnded({ laneId, sessionId }); + try { + issueInventoryService.reconcileConvergenceSessionExit(sessionId, { exitCode }); + } catch (error) { + logger.warn("main.convergence_session_reconcile_failed", { + laneId, + sessionId, + exitCode, + error: error instanceof Error ? error.message : String(error), + }); + } void linearSyncServiceRef?.processActiveRunsNow().catch(() => {}); if (orchestratorServiceRef) { void orchestratorServiceRef @@ -1362,6 +1375,7 @@ app.whenReady().then(async () => { linearClient, linearCredentials: linearCredentialService, prService, + issueInventoryService, processService, getTestService: () => testServiceRef, ptyService, @@ -2239,47 +2253,64 @@ app.whenReady().then(async () => { computerUseArtifactBrokerService, orchestratorService, aiOrchestratorService, + issueInventoryService, eventBuffer: mcpEventBuffer, dispose: () => {} // desktop manages service lifecycle }; const mcpSocketPath = adePaths.socketPath; + const activeMcpConnections = new Set(); + + const destroyActiveMcpConnections = (): void => { + for (const conn of activeMcpConnections) { + activeMcpConnections.delete(conn); + try { + conn.destroy(); + } catch { + // ignore + } + } + }; + mcpSocketCleanupByRoot.set(normalizeProjectRoot(projectRoot), destroyActiveMcpConnections); // Clean stale socket from prior crash try { fs.unlinkSync(mcpSocketPath); } catch {} const mcpSocketServer = net.createServer((conn) => { + activeMcpConnections.add(conn); + let stopped = false; + const transport: JsonRpcTransport = { + onData(callback) { + conn.on("data", callback); + }, + write(data) { + conn.write(data); + }, + close() { + if (!conn.destroyed) conn.destroy(); + }, + }; + let stop: ReturnType | null = null; const mcpHandler = createMcpRequestHandler({ runtime: mcpRuntime, serverVersion: app.getVersion(), onToolsListChanged: () => { - conn.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed", params: {} })}\n`); + stop?.notify("notifications/tools/list_changed", {}); }, }); - let buf = ""; + stop = startJsonRpcServer(mcpHandler, transport, { nonFatal: true }); + const removeConnection = (): void => { + activeMcpConnections.delete(conn); + }; + conn.once("close", removeConnection); + conn.once("end", removeConnection); + conn.once("error", removeConnection); conn.on("close", () => { - mcpHandler.dispose(); - }); - conn.on("data", (chunk) => { - buf += chunk.toString(); - let nl: number; - while ((nl = buf.indexOf("\n")) !== -1) { - const line = buf.slice(0, nl).trim(); - buf = buf.slice(nl + 1); - if (!line) continue; - let parsed: any; - try { parsed = JSON.parse(line); } catch { continue; } - const id = parsed.id ?? null; - void mcpHandler(parsed).then((result) => { - if (id !== null && id !== undefined) { - conn.write(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }) + "\n"); - } - }).catch((err: any) => { - if (id !== null && id !== undefined) { - conn.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32603, message: err?.message ?? String(err) } }) + "\n"); - } - }); + if (!stopped) { + stopped = true; + stop?.(); } + mcpHandler.dispose(); }); conn.on("error", () => {}); // ignore connection errors }); @@ -2464,6 +2495,25 @@ app.whenReady().then(async () => { }; const disposeContextResources = async (ctx: AppContext): Promise => { + const normalizedRoot = typeof ctx.project?.rootPath === "string" && ctx.project.rootPath.trim().length > 0 + ? normalizeProjectRoot(ctx.project.rootPath) + : null; + // Tear down MCP socket BEFORE any service disposal so in-flight MCP requests + // do not race with services that are being shut down. + try { + if (normalizedRoot) { + mcpSocketCleanupByRoot.get(normalizedRoot)?.(); + mcpSocketCleanupByRoot.delete(normalizedRoot); + } + ctx.mcpSocketServer?.close(); + } catch { + // ignore + } + try { + if (ctx.mcpSocketPath) fs.unlinkSync(ctx.mcpSocketPath); + } catch { + // ignore + } // Flush DB before disposing services so that any pending writes are persisted. // Services may write during disposal, so we flush again at the end as a safety net. try { @@ -2586,16 +2636,6 @@ app.whenReady().then(async () => { } catch { // ignore } - try { - ctx.mcpSocketServer?.close(); - } catch { - // ignore - } - try { - if (ctx.mcpSocketPath) fs.unlinkSync(ctx.mcpSocketPath); - } catch { - // ignore - } try { ctx.db.flushNow(); ctx.db.close(); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 7af802f26..e512bee52 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -829,7 +829,14 @@ export function createAiIntegrationService(args: { const auth = await detectAuth(); const available = await getResolvedAvailableModels(auth); - const family = provider === "codex" ? "openai" : provider === "cursor" ? "cursor" : "anthropic"; + let family: string; + if (provider === "codex") { + family = "openai"; + } else if (provider === "cursor") { + family = "cursor"; + } else { + family = "anthropic"; + } const models = available .filter((descriptor) => descriptor.family === family) .map((descriptor) => ({ diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.ts b/apps/desktop/src/main/services/ai/apiKeyStore.ts index 1613506ea..246bc163e 100644 --- a/apps/desktop/src/main/services/ai/apiKeyStore.ts +++ b/apps/desktop/src/main/services/ai/apiKeyStore.ts @@ -1,8 +1,24 @@ import fs from "node:fs"; import path from "node:path"; -import { safeStorage } from "electron"; +import type { SafeStorage } from "electron"; import { resolveAdeLayout } from "../../../shared/adeLayout"; +// electron.safeStorage is only available inside an Electron main process. +// When this module is bundled into the headless MCP server (spawned by +// Claude Agent SDK / Codex App Server as a plain Node process), `electron` +// is not present. Gracefully degrade so the MCP server can start. +let safeStorage: SafeStorage | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + safeStorage = require("electron").safeStorage; +} catch (err) { + // Not running inside Electron — secure storage unavailable. + // Log at debug level so silent failures don't hide useful diagnostics. + if (typeof process !== "undefined" && process.env.DEBUG) { + console.debug("[apiKeyStore] electron.safeStorage unavailable:", err); + } +} + type StoredKeys = Record; export type ApiKeyStoreStatus = { @@ -64,7 +80,7 @@ function ensureStore(): StoredKeys { try { const raw = fs.readFileSync(storePath); - const decrypted = safeStorage.decryptString(raw); + const decrypted = safeStorage!.decryptString(raw); cache = normalizeStoredKeys(JSON.parse(decrypted)); decryptionFailed = false; return cache; @@ -81,7 +97,7 @@ function persist(): void { throw new Error("OS secure storage is unavailable. Cannot persist API keys."); } fs.mkdirSync(path.dirname(storePath), { recursive: true }); - const encrypted = safeStorage.encryptString(JSON.stringify(cache)); + const encrypted = safeStorage!.encryptString(JSON.stringify(cache)); fs.writeFileSync(storePath, encrypted); try { fs.chmodSync(storePath, 0o600); diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index e47ead5e5..177e4eaf6 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -186,7 +186,13 @@ async function refreshProcessPathFromShell(): Promise { /** JSON fields that indicate a positive login state across CLI versions. */ const JSON_AUTH_FIELDS = ["loggedIn", "logged_in", "authenticated", "signedIn", "signed_in", "active"] as const; -function parseJsonAuthStatus(stdout: string): { authenticated: boolean; verified: true } | null { +type ParsedJsonAuthStatus = { + authenticated: boolean; + verified: true; + json: Record; +} | null; + +function parseJsonAuthStatus(stdout: string): ParsedJsonAuthStatus { try { const json = JSON.parse(stdout.trim() || ""); if (typeof json !== "object" || json === null) return null; @@ -194,7 +200,7 @@ function parseJsonAuthStatus(stdout: string): { authenticated: boolean; verified // Check well-known boolean fields for (const field of JSON_AUTH_FIELDS) { if (field in json) { - return { authenticated: Boolean(json[field]), verified: true }; + return { authenticated: Boolean(json[field]), verified: true, json: json as Record }; } } @@ -204,7 +210,7 @@ function parseJsonAuthStatus(stdout: string): { authenticated: boolean; verified (typeof json.email === "string" && json.email.trim().length > 0) || (typeof json.account === "string" && json.account.trim().length > 0) ) { - return { authenticated: true, verified: true }; + return { authenticated: true, verified: true, json: json as Record }; } } catch { // Not JSON — fall through to regex matching. @@ -307,54 +313,27 @@ async function inspectCursorCliAuthentication(command: string): Promise<{ for (const args of probes) { try { const result = await spawnAsync(command, args, { timeout: 8_000 }); - const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); - const normalized = output.toLowerCase(); + const stdout = result.stdout ?? ""; + const normalized = `${stdout}\n${result.stderr ?? ""}`.trim().toLowerCase(); - try { - const json = JSON.parse(result.stdout?.trim() || "") as Record; - if (json && typeof json === "object") { - const jsonAuth = parseJsonAuthStatus(result.stdout ?? ""); - if (jsonAuth) { - return { - authenticated: jsonAuth.authenticated, - verified: true, - paidPlan: inferCursorPaidPlanFromJson(json), - }; - } - } - } catch { - // not JSON - } - - const jsonResult = parseJsonAuthStatus(result.stdout ?? ""); - if (jsonResult) { - let paidPlan = true; - try { - const parsed = JSON.parse(result.stdout?.trim() || "") as Record; - if (parsed && typeof parsed === "object") { - paidPlan = inferCursorPaidPlanFromJson(parsed); - } - } catch { - // Not JSON — fall back to paidPlan = true - } + // Try structured JSON auth first + const jsonAuth = parseJsonAuthStatus(stdout); + if (jsonAuth) { + const paidPlan = inferCursorPaidPlanFromJson(jsonAuth.json); return { - authenticated: jsonResult.authenticated, + authenticated: jsonAuth.authenticated, verified: true, paidPlan, }; } - const matchesStrongUnauth = hasPattern(normalized, STRONG_UNAUTH_INDICATORS); - if (matchesStrongUnauth) { + if (hasPattern(normalized, STRONG_UNAUTH_INDICATORS)) { return { authenticated: false, verified: true, paidPlan: false }; } - - const matchesAuth = hasPattern(normalized, AUTH_INDICATORS); - const matchesWeakUnauth = hasPattern(normalized, WEAK_UNAUTH_INDICATORS); - if (matchesAuth) { + if (hasPattern(normalized, AUTH_INDICATORS)) { return { authenticated: true, verified: true, paidPlan: true }; } - if (matchesWeakUnauth) { + if (hasPattern(normalized, WEAK_UNAUTH_INDICATORS)) { return { authenticated: false, verified: true, paidPlan: false }; } @@ -362,9 +341,6 @@ async function inspectCursorCliAuthentication(command: string): Promise<{ return { authenticated: true, verified: true, paidPlan: true }; } - // If exit 0 with non-empty output reached here, all regex branches above - // already returned, so a separate `normalized.length > 0` check is unreachable. - if (hasPattern(normalized, UNSUPPORTED_INDICATORS)) { sawUnsupported = true; } diff --git a/apps/desktop/src/main/services/ai/codexAppServerConfig.test.ts b/apps/desktop/src/main/services/ai/codexAppServerConfig.test.ts new file mode 100644 index 000000000..5d052f518 --- /dev/null +++ b/apps/desktop/src/main/services/ai/codexAppServerConfig.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { buildCodexAppServerMcpConfigOverrides } from "./codexAppServerConfig"; + +describe("buildCodexAppServerMcpConfigOverrides", () => { + it("maps ADE stdio MCP server settings into Codex app-server config overrides", () => { + const result = buildCodexAppServerMcpConfigOverrides({ + ade: { + transport: "stdio", + command: "node", + args: ["/tmp/mcp-server.js"], + env: { ADE_RUN_ID: "run-1" }, + required: true, + startup_timeout_sec: 30, + tool_timeout_sec: 120, + }, + }); + + expect(result).toEqual({ + "mcp_servers.ade.required": true, + "mcp_servers.ade.startup_timeout_sec": 30, + "mcp_servers.ade.tool_timeout_sec": 120, + "mcp_servers.ade.command": "node", + "mcp_servers.ade.args": ["/tmp/mcp-server.js"], + "mcp_servers.ade.env": { ADE_RUN_ID: "run-1" }, + }); + }); + + it("supports camelCase timeout keys and HTTP MCP servers", () => { + const result = buildCodexAppServerMcpConfigOverrides({ + docs: { + transport: "http", + url: "https://mcp.example.com", + startupTimeoutSec: 15, + toolTimeoutSec: 45, + httpHeaders: { "x-tenant": "acme" }, + envHttpHeaders: { Authorization: "MCP_AUTH" }, + }, + }); + + expect(result).toEqual({ + "mcp_servers.docs.startup_timeout_sec": 15, + "mcp_servers.docs.tool_timeout_sec": 45, + "mcp_servers.docs.url": "https://mcp.example.com", + "mcp_servers.docs.http_headers": { "x-tenant": "acme" }, + "mcp_servers.docs.env_http_headers": { Authorization: "MCP_AUTH" }, + }); + }); + + it("returns undefined when no MCP servers are configured", () => { + expect(buildCodexAppServerMcpConfigOverrides()).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/main/services/ai/codexAppServerConfig.ts b/apps/desktop/src/main/services/ai/codexAppServerConfig.ts new file mode 100644 index 000000000..572664be2 --- /dev/null +++ b/apps/desktop/src/main/services/ai/codexAppServerConfig.ts @@ -0,0 +1,91 @@ +type McpServerRecord = Record; + +function stringArrayOrUndefined(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const normalized = value.filter((entry): entry is string => typeof entry === "string"); + return normalized.length === value.length ? normalized : undefined; +} + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function isStringRecord(value: unknown): value is Record { + if (value == null || typeof value !== "object" || Array.isArray(value)) return false; + return Object.entries(value as Record).every( + ([k, v]) => typeof k === "string" && typeof v === "string", + ); +} + +export function buildCodexAppServerMcpConfigOverrides( + mcpServers?: Record, +): Record | undefined { + if (!mcpServers) return undefined; + + const overrides: Record = {}; + + for (const [name, server] of Object.entries(mcpServers)) { + const prefix = `mcp_servers.${name}`; + + const required = typeof server.required === "boolean" ? server.required : undefined; + if (required !== undefined) { + overrides[`${prefix}.required`] = required; + } + + const enabled = typeof server.enabled === "boolean" ? server.enabled : undefined; + if (enabled !== undefined) { + overrides[`${prefix}.enabled`] = enabled; + } + + const startupTimeoutSec = + (isFiniteNonNegative(server.startupTimeoutSec) ? server.startupTimeoutSec : undefined) + ?? (isFiniteNonNegative(server.startup_timeout_sec) ? server.startup_timeout_sec : undefined); + if (startupTimeoutSec !== undefined) { + overrides[`${prefix}.startup_timeout_sec`] = startupTimeoutSec; + } + + const toolTimeoutSec = + (isFiniteNonNegative(server.toolTimeoutSec) ? server.toolTimeoutSec : undefined) + ?? (isFiniteNonNegative(server.tool_timeout_sec) ? server.tool_timeout_sec : undefined); + if (toolTimeoutSec !== undefined) { + overrides[`${prefix}.tool_timeout_sec`] = toolTimeoutSec; + } + + const enabledTools = stringArrayOrUndefined(server.enabledTools) + ?? stringArrayOrUndefined(server.enabled_tools); + if (enabledTools !== undefined) { + overrides[`${prefix}.enabled_tools`] = enabledTools; + } + + const disabledTools = stringArrayOrUndefined(server.disabledTools) + ?? stringArrayOrUndefined(server.disabled_tools); + if (disabledTools !== undefined) { + overrides[`${prefix}.disabled_tools`] = disabledTools; + } + + if (typeof server.command === "string" && server.command.trim().length > 0) { + overrides[`${prefix}.command`] = server.command; + const args = stringArrayOrUndefined(server.args); + if (args !== undefined) overrides[`${prefix}.args`] = args; + if (isStringRecord(server.env)) overrides[`${prefix}.env`] = server.env; + if (typeof server.cwd === "string" && server.cwd.trim().length > 0) overrides[`${prefix}.cwd`] = server.cwd; + continue; + } + + if (typeof server.url === "string" && server.url.trim().length > 0) { + overrides[`${prefix}.url`] = server.url; + if (typeof server.bearerToken === "string") overrides[`${prefix}.bearer_token`] = server.bearerToken; + if (typeof server.bearerTokenEnvVar === "string") { + overrides[`${prefix}.bearer_token_env_var`] = server.bearerTokenEnvVar; + } + if (isStringRecord(server.httpHeaders)) { + overrides[`${prefix}.http_headers`] = server.httpHeaders; + } + if (isStringRecord(server.envHttpHeaders)) { + overrides[`${prefix}.env_http_headers`] = server.envHttpHeaders; + } + } + } + + return Object.keys(overrides).length > 0 ? overrides : undefined; +} diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 8cc00a704..4bc88eb34 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -90,13 +90,13 @@ export async function buildProviderConnections( health: ReturnType | null, ): void { if (!health) return; - if (health?.state === "auth-failed" || health?.state === "runtime-failed") { + if (health.state === "auth-failed" || health.state === "runtime-failed") { status.runtimeAvailable = false; status.blocker = health.message ?? (health.state === "auth-failed" ? `${status.provider} runtime was detected, but ADE chat reported that login is still required.` : `${status.provider} runtime was detected, but ADE could not launch it from this app session.`); - } else if (health?.state === "ready") { + } else if (health.state === "ready") { status.runtimeAvailable = true; status.authAvailable = true; status.blocker = null; @@ -193,8 +193,6 @@ export async function buildProviderConnections( let cursorBlocker: string | null = null; if (!cursorFlags.authAvailable && !cursorFlags.runtimeDetected) { cursorBlocker = "No Cursor CLI (`agent`) or Cursor credentials were found locally."; - } else if (cursorFlags.cliExplicitlyUnauthenticated && !cursorEnvAuth) { - cursorBlocker = "Cursor CLI (`agent`) is installed but no login was detected. Run: agent login"; } else if (!cursorFlags.authAvailable) { cursorBlocker = "Cursor CLI (`agent`) is installed but no login was detected. Run: agent login"; } else if (!cursorFlags.runtimeDetected) { diff --git a/apps/desktop/src/main/services/ai/providerResolver.test.ts b/apps/desktop/src/main/services/ai/providerResolver.test.ts index 36fe877e3..155294de1 100644 --- a/apps/desktop/src/main/services/ai/providerResolver.test.ts +++ b/apps/desktop/src/main/services/ai/providerResolver.test.ts @@ -64,6 +64,9 @@ describe("providerResolver codex CLI", () => { env: { ADE_RUN_ID: "run-1", }, + required: true, + startup_timeout_sec: 30, + tool_timeout_sec: 120, }, }, }, @@ -82,6 +85,9 @@ describe("providerResolver codex CLI", () => { env: { ADE_RUN_ID: "run-1", }, + required: true, + startup_timeout_sec: 30, + tool_timeout_sec: 120, }, }, }), @@ -162,6 +168,9 @@ describe("providerResolver codex CLI", () => { command: "node", args: ["/tmp/mcp-server.js"], env: { ADE_RUN_ID: "run-1" }, + required: true, + startup_timeout_sec: 30, + tool_timeout_sec: 120, }, }; @@ -171,6 +180,9 @@ describe("providerResolver codex CLI", () => { command: "node", args: ["/tmp/mcp-server.js"], env: { ADE_RUN_ID: "run-1" }, + required: true, + startup_timeout_sec: 30, + tool_timeout_sec: 120, }, }); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index c3ec81ca5..f6cb2cf88 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -44,8 +44,12 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo linearDispatcherService: null, flowPolicyService: null, prService: null, + issueInventoryService: null, fileService: null, processService: null, + sessionService: { + updateMeta: vi.fn(), + } as any, issueTracker: null, listChats: vi.fn().mockResolvedValue([]), getChatStatus: vi.fn().mockResolvedValue(null), @@ -57,6 +61,13 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo }), createChat: vi.fn().mockResolvedValue(baseSession), updateChatSession: vi.fn().mockResolvedValue(baseSession), + previewSessionToolNames: vi.fn(() => [ + "prRefreshIssueInventory", + "prGetReviewComments", + "prRerunFailedChecks", + "prReplyToReviewThread", + "prResolveReviewThread", + ]), sendChatMessage: vi.fn().mockResolvedValue(undefined), interruptChat: vi.fn().mockResolvedValue(undefined), resumeChat: vi.fn().mockResolvedValue(baseSession), @@ -121,6 +132,11 @@ describe("createCtoOperatorTools", () => { expect(toolKeys).toContain("commentOnPullRequest"); expect(toolKeys).toContain("updatePullRequestTitle"); expect(toolKeys).toContain("updatePullRequestBody"); + expect(toolKeys).toContain("getPullRequestConvergence"); + expect(toolKeys).toContain("updatePullRequestConvergencePipeline"); + expect(toolKeys).toContain("updatePullRequestConvergenceRuntime"); + expect(toolKeys).toContain("startPullRequestConvergenceRound"); + expect(toolKeys).toContain("stopPullRequestConvergence"); // Linear issue routing / issue tools expect(toolKeys).toContain("routeLinearIssueToCto"); @@ -844,6 +860,359 @@ describe("createCtoOperatorTools", () => { expect(result).toMatchObject({ success: true, prId: "pr-1" }); }); + + it("reads PR convergence runtime, pipeline settings, and inventory summary", async () => { + const snapshot = { + prId: "pr-1", + items: [ + { + id: "item-1", + prId: "pr-1", + source: "unknown", + type: "review_thread", + externalId: "review-thread:thread-1", + state: "new", + round: 2, + filePath: "src/app.ts", + line: 12, + severity: "major", + headline: "Fix the null check", + body: "Please handle null.", + author: "Reviewer", + url: "https://github.com/acme/repo/pull/1#discussion_r1", + dismissReason: null, + agentSessionId: null, + threadCommentCount: 2, + threadLatestCommentId: "c-2", + threadLatestCommentAuthor: "Reviewer", + threadLatestCommentAt: "2026-03-16T00:00:00.000Z", + threadLatestCommentSource: "unknown", + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }, + ], + convergence: { + currentRound: 2, + maxRounds: 5, + issuesPerRound: [{ round: 2, newCount: 1, fixedCount: 0, dismissedCount: 0 }], + totalNew: 1, + totalFixed: 0, + totalDismissed: 0, + totalEscalated: 0, + totalSentToAgent: 0, + isConverging: false, + canAutoAdvance: true, + }, + }; + const runtime = { + prId: "pr-1", + autoConvergeEnabled: true, + status: "running", + pollerStatus: "waiting_for_comments", + currentRound: 2, + activeSessionId: "session-1", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-1", + pauseReason: null, + errorMessage: null, + lastStartedAt: "2026-03-16T00:00:00.000Z", + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }; + const pipelineSettings = { + autoMerge: true, + mergeMethod: "squash", + maxRounds: 4, + onRebaseNeeded: "pause", + }; + const deps = buildDeps({ + prService: { + listAll: vi.fn().mockReturnValue([issueFixture, { ...issueFixture, id: "pr-1", title: "Path to merge", laneId: "lane-1" }]), + getStatus: vi.fn().mockResolvedValue({ + prId: "pr-1", + state: "open", + checksStatus: "pending", + reviewStatus: "changes_requested", + isMergeable: false, + mergeConflicts: false, + behindBaseBy: 0, + }), + getChecks: vi.fn().mockResolvedValue([{ name: "CI", status: "completed", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null }]), + getReviewThreads: vi.fn().mockResolvedValue([{ id: "thread-1", isResolved: false, isOutdated: false, path: "src/app.ts", line: 12, originalLine: 12, startLine: null, originalStartLine: null, diffSide: "RIGHT", url: "https://github.com/acme/repo/pull/1#discussion_r1", createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z", comments: [{ id: "c-1", author: "Reviewer", authorAvatarUrl: null, body: "Please handle null.", url: null, createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z" }, { id: "c-2", author: "Reviewer", authorAvatarUrl: null, body: "Still needs a guard.", url: null, createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z" }] }]), + getComments: vi.fn().mockResolvedValue([{ id: "comment-1", author: "Reviewer", authorAvatarUrl: null, body: "Please handle null.", source: "issue", url: null, path: "src/app.ts", line: 12, createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z" }]), + } as any, + issueInventoryService: { + syncFromPrData: vi.fn().mockReturnValue(snapshot), + getConvergenceRuntime: vi.fn().mockReturnValue(runtime), + getPipelineSettings: vi.fn().mockReturnValue(pipelineSettings), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.getPullRequestConvergence as any).execute({ prId: "pr-1" }); + + expect((deps.issueInventoryService as any).syncFromPrData).toHaveBeenCalledWith( + "pr-1", + expect.any(Array), + expect.any(Array), + expect.any(Array), + ); + expect(result).toMatchObject({ + success: true, + pr: expect.objectContaining({ id: "pr-1", title: "Path to merge" }), + runtime, + pipelineSettings, + inventory: { + summary: expect.objectContaining({ + currentRound: 2, + totalNew: 1, + }), + items: [expect.objectContaining({ + id: "item-1", + latestComment: expect.objectContaining({ id: "c-2", author: "Reviewer" }), + })], + }, + }); + }); + + it("updates convergence pipeline and runtime state", async () => { + const deps = buildDeps({ + issueInventoryService: { + savePipelineSettings: vi.fn(), + getPipelineSettings: vi.fn().mockReturnValue({ + autoMerge: true, + mergeMethod: "merge", + maxRounds: 3, + onRebaseNeeded: "auto_rebase", + }), + saveConvergenceRuntime: vi.fn().mockReturnValue({ + prId: "pr-1", + autoConvergeEnabled: true, + status: "running", + pollerStatus: "scheduled", + currentRound: 3, + activeSessionId: "session-1", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-1", + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }), + } as any, + }); + const tools = createCtoOperatorTools(deps); + + const pipeline = await (tools.updatePullRequestConvergencePipeline as any).execute({ + prId: "pr-1", + autoMerge: true, + mergeMethod: "merge", + maxRounds: 3, + onRebaseNeeded: "auto_rebase", + }); + const runtime = await (tools.updatePullRequestConvergenceRuntime as any).execute({ + prId: "pr-1", + autoConvergeEnabled: true, + status: "running", + pollerStatus: "scheduled", + currentRound: 3, + activeSessionId: "session-1", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-1", + }); + + expect((deps.issueInventoryService as any).savePipelineSettings).toHaveBeenCalledWith("pr-1", { + autoMerge: true, + mergeMethod: "merge", + maxRounds: 3, + onRebaseNeeded: "auto_rebase", + }); + expect((deps.issueInventoryService as any).saveConvergenceRuntime).toHaveBeenCalledWith("pr-1", expect.objectContaining({ + autoConvergeEnabled: true, + status: "running", + pollerStatus: "scheduled", + currentRound: 3, + })); + expect(pipeline).toMatchObject({ success: true, pipelineSettings: { autoMerge: true, mergeMethod: "merge" } }); + expect(runtime).toMatchObject({ success: true, runtime: expect.objectContaining({ currentRound: 3 }) }); + }); + + it("launches and stops a PR convergence round through chat services", async () => { + const deps = buildDeps({ + prService: { + listAll: vi.fn().mockReturnValue([{ ...issueFixture, id: "pr-1", laneId: "lane-1" }]), + getStatus: vi.fn().mockResolvedValue({ + prId: "pr-1", + state: "open", + checksStatus: "failing", + reviewStatus: "changes_requested", + isMergeable: false, + mergeConflicts: false, + behindBaseBy: 0, + }), + getChecks: vi.fn().mockResolvedValue([]), + getReviewThreads: vi.fn().mockResolvedValue([{ id: "thread-1", isResolved: false, isOutdated: false, path: "src/app.ts", line: 12, originalLine: 12, startLine: null, originalStartLine: null, diffSide: "RIGHT", url: "https://github.com/acme/repo/pull/1#discussion_r1", createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z", comments: [{ id: "c-1", author: "Reviewer", authorAvatarUrl: null, body: "Please handle null.", url: null, createdAt: "2026-03-16T00:00:00.000Z", updatedAt: "2026-03-16T00:00:00.000Z" }] }]), + getComments: vi.fn().mockResolvedValue([]), + getDetail: vi.fn().mockResolvedValue(null), + getFiles: vi.fn().mockResolvedValue([]), + getActionRuns: vi.fn().mockResolvedValue([]), + } as any, + laneService: { + list: vi.fn().mockResolvedValue([{ id: "lane-1", worktreePath: "/tmp", archivedAt: null }]), + getLaneBaseAndBranch: vi.fn().mockResolvedValue({ baseBranch: "main", headBranch: "feature" }), + } as any, + issueInventoryService: { + syncFromPrData: vi.fn().mockReturnValue({ + prId: "pr-1", + items: [ + { + id: "inventory-item-1", + prId: "pr-1", + source: "unknown", + type: "review_thread", + externalId: "review-thread:thread-1", + state: "new", + round: 1, + filePath: "src/app.ts", + line: 12, + severity: "major", + headline: "Please handle null.", + body: "Please handle null.", + author: "Reviewer", + url: "https://github.com/acme/repo/pull/1#discussion_r1", + dismissReason: null, + agentSessionId: null, + threadCommentCount: 1, + threadLatestCommentId: "c-1", + threadLatestCommentAuthor: "Reviewer", + threadLatestCommentAt: "2026-03-16T00:00:00.000Z", + threadLatestCommentSource: "unknown", + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }, + ], + convergence: { + currentRound: 1, + maxRounds: 5, + issuesPerRound: [{ round: 1, newCount: 1, fixedCount: 0, dismissedCount: 0 }], + totalNew: 1, + totalFixed: 0, + totalDismissed: 0, + totalEscalated: 0, + totalSentToAgent: 0, + isConverging: false, + canAutoAdvance: true, + }, + }), + getNewItems: vi.fn().mockReturnValue([{ id: "inventory-item-1" }]), + markSentToAgent: vi.fn(), + getConvergenceRuntime: vi.fn().mockReturnValue({ + prId: "pr-1", + autoConvergeEnabled: true, + status: "running", + pollerStatus: "waiting_for_comments", + currentRound: 1, + activeSessionId: "session-2", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-2", + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }), + getPipelineSettings: vi.fn().mockReturnValue({ + autoMerge: true, + mergeMethod: "merge", + maxRounds: 5, + onRebaseNeeded: "pause", + }), + getConvergenceStatus: vi.fn().mockReturnValue({ + currentRound: 2, + maxRounds: 5, + issuesPerRound: [], + totalNew: 1, + totalFixed: 0, + totalDismissed: 0, + totalEscalated: 0, + totalSentToAgent: 1, + isConverging: true, + canAutoAdvance: false, + }), + saveConvergenceRuntime: vi.fn().mockReturnValue({ + prId: "pr-1", + autoConvergeEnabled: true, + status: "running", + pollerStatus: "waiting_for_comments", + currentRound: 2, + activeSessionId: "session-2", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-2", + pauseReason: null, + errorMessage: null, + lastStartedAt: "2026-03-16T00:00:00.000Z", + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-03-16T00:00:00.000Z", + updatedAt: "2026-03-16T00:00:00.000Z", + }), + } as any, + sessionService: { + updateMeta: vi.fn(), + } as any, + createChat: vi.fn().mockResolvedValue({ ...baseSession, id: "session-2", laneId: "lane-1" }), + sendChatMessage: vi.fn().mockResolvedValue(undefined), + interruptChat: vi.fn().mockResolvedValue(undefined), + }); + const tools = createCtoOperatorTools(deps); + + const started = await (tools.startPullRequestConvergenceRound as any).execute({ + prId: "pr-1", + scope: "comments", + modelId: "openai/gpt-5.4", + additionalInstructions: "Be concise.", + }); + const stopped = await (tools.stopPullRequestConvergence as any).execute({ + prId: "pr-1", + sessionId: "session-2", + reason: "Stop for now.", + }); + + expect(started).toMatchObject({ success: true }); + expect((deps.issueInventoryService as any).markSentToAgent).toHaveBeenCalledWith( + "pr-1", + ["inventory-item-1"], + "session-2", + 2, + ); + expect((deps.interruptChat as any)).toHaveBeenCalledWith({ sessionId: "session-2" }); + expect((deps.issueInventoryService as any).saveConvergenceRuntime).toHaveBeenCalledWith("pr-1", expect.objectContaining({ + status: "stopped", + pollerStatus: "stopped", + autoConvergeEnabled: false, + })); + expect(started).toMatchObject({ + success: true, + sessionId: "session-2", + laneId: "lane-1", + href: "/work?laneId=lane-1&sessionId=session-2", + }); + expect(stopped).toMatchObject({ + success: true, + sessionId: "session-2", + }); + }); }); // ── Linear workflow tools ─────────────────────────────────────── diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 2001d9ba5..6cfe1a982 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -1,6 +1,6 @@ import { tool, type Tool } from "ai"; import { z } from "zod"; -import { getModelById } from "../../../../shared/modelRegistry"; +import { getModelById, resolveChatProviderForDescriptor } from "../../../../shared/modelRegistry"; import type { AgentChatCreateArgs, AgentChatInterruptArgs, @@ -18,6 +18,18 @@ import type { TestRunSummary, TestSuiteDefinition, } from "../../../../shared/types"; +import type { + ConvergenceRuntimeState, + ConvergenceStatus, + IssueInventoryItem, + IssueSource, + PipelineSettings, + PrCheck, + PrComment, + PrReviewThread, + PrSummary, +} from "../../../../shared/types/prs"; +import { DEFAULT_PIPELINE_SETTINGS } from "../../../../shared/types/prs"; import type { IssueTracker } from "../../cto/issueTracker"; import type { createLinearDispatcherService } from "../../cto/linearDispatcherService"; import type { createWorkerAgentService } from "../../cto/workerAgentService"; @@ -27,9 +39,14 @@ import type { createFileService } from "../../files/fileService"; import type { createLaneService } from "../../lanes/laneService"; import type { createMissionService } from "../../missions/missionService"; import type { createAiOrchestratorService } from "../../orchestrator/aiOrchestratorService"; +import type { createIssueInventoryService } from "../../prs/issueInventoryService"; +import { computeConvergenceStatus, detectSource, extractSeverity } from "../../prs/issueInventoryService"; +import { launchPrIssueResolutionChat } from "../../prs/prIssueResolver"; import type { createPrService } from "../../prs/prService"; +import { isNoisyIssueComment, mapPermissionMode } from "../../prs/resolverUtils"; import type { createProcessService } from "../../processes/processService"; -import { getErrorMessage } from "../../shared/utils"; +import type { createSessionService } from "../../sessions/sessionService"; +import { getErrorMessage, nowIso } from "../../shared/utils"; export interface CtoOperatorToolDeps { currentSessionId: string; @@ -50,8 +67,10 @@ export interface CtoOperatorToolDeps { linearDispatcherService?: ReturnType | null; flowPolicyService?: ReturnType | null; prService?: ReturnType | null; + issueInventoryService?: ReturnType | null; fileService?: ReturnType | null; processService?: ReturnType | null; + sessionService: Pick, "updateMeta">; testService?: { listSuites: () => TestSuiteDefinition[]; run: (args: { laneId: string; suiteId: string }) => Promise; @@ -90,6 +109,10 @@ export interface CtoOperatorToolDeps { sessionId: string; title?: string | null; }) => Promise; + previewSessionToolNames: (args: { + laneId: string; + sessionProfile?: AgentChatCreateArgs["sessionProfile"]; + }) => string[]; sendChatMessage: (args: AgentChatSendArgs) => Promise; interruptChat: (args: AgentChatInterruptArgs) => Promise; resumeChat: (args: { sessionId: string }) => Promise; @@ -115,18 +138,9 @@ const ACTIVE_LINEAR_RUN_STATUSES = new Set([ function deriveChatProvider(args: { modelId?: string | null }): { provider: AgentChatCreateArgs["provider"]; model: string } { const descriptor = args.modelId ? getModelById(args.modelId) : null; if (!descriptor) { - return { - provider: "unified", - model: args.modelId?.trim() || "", - }; - } - if (!descriptor.isCliWrapped) { - return { provider: "unified", model: descriptor.id }; + return { provider: "unified", model: args.modelId?.trim() || "" }; } - if (descriptor.family === "openai") { - return { provider: "codex", model: descriptor.shortId }; - } - return { provider: "claude", model: descriptor.shortId }; + return resolveChatProviderForDescriptor(descriptor); } function buildIssueBrief(issue: Awaited>): string { @@ -253,6 +267,237 @@ function resolveWorkspaceIdForLane( throw new Error(`Workspace not found for lane ${laneId}.`); } +function extractSeverityFromText(text: string | null | undefined): IssueInventoryItem["severity"] { + return extractSeverity(String(text ?? "")); +} + +function truncateForHeadline(text: string, max = 140): string { + const normalized = text.trim().replace(/\s+/g, " "); + if (normalized.length <= max) return normalized; + return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…`; +} + +function summarizeInventoryItems(items: IssueInventoryItem[], maxRounds: number): ConvergenceStatus { + return computeConvergenceStatus(items, maxRounds); +} + +function buildFallbackInventoryItems(args: { + prId: string; + checks: PrCheck[]; + reviewThreads: PrReviewThread[]; + comments: PrComment[]; +}): IssueInventoryItem[] { + const items: IssueInventoryItem[] = []; + const timestamp = nowIso(); + + for (const thread of args.reviewThreads) { + const latestComment = thread.comments.at(-1) ?? null; + const headlineSource = latestComment?.body?.trim() || thread.comments[0]?.body?.trim() || thread.path || `Review thread ${thread.id}`; + const body = thread.comments + .map((entry) => entry.body?.trim() || "") + .filter(Boolean) + .join("\n\n") + .trim() || null; + const author = latestComment?.author ?? null; + const source: IssueSource = detectSource(author); + items.push({ + id: `transient-thread-${thread.id}`, + prId: args.prId, + source, + type: "review_thread", + externalId: `review-thread:${thread.id}`, + state: thread.isResolved || thread.isOutdated ? "fixed" : "new", + round: 0, + filePath: thread.path, + line: thread.line, + severity: extractSeverityFromText(body ?? headlineSource), + headline: truncateForHeadline(headlineSource), + body, + author, + url: thread.url, + dismissReason: null, + agentSessionId: null, + threadCommentCount: thread.comments.length, + threadLatestCommentId: latestComment?.id ?? null, + threadLatestCommentAuthor: latestComment?.author ?? null, + threadLatestCommentAt: latestComment?.createdAt ?? null, + threadLatestCommentSource: source, + createdAt: thread.createdAt ?? timestamp, + updatedAt: thread.updatedAt ?? thread.createdAt ?? timestamp, + }); + } + + for (const comment of args.comments) { + if (comment.source !== "issue") continue; + if (isNoisyIssueComment(comment)) continue; + const source = detectSource(comment.author); + if (source !== "human") continue; + const body = comment.body?.trim() || null; + items.push({ + id: `transient-comment-${comment.id}`, + prId: args.prId, + source, + type: "issue_comment", + externalId: `issue-comment:${comment.id}`, + state: "new", + round: 0, + filePath: comment.path, + line: comment.line, + severity: extractSeverityFromText(body), + headline: body ? truncateForHeadline(body) : `Issue comment by ${comment.author}`, + body, + author: comment.author, + url: comment.url, + dismissReason: null, + agentSessionId: null, + threadCommentCount: null, + threadLatestCommentId: null, + threadLatestCommentAuthor: null, + threadLatestCommentAt: null, + threadLatestCommentSource: null, + createdAt: comment.createdAt ?? timestamp, + updatedAt: comment.updatedAt ?? comment.createdAt ?? timestamp, + }); + } + + for (const check of args.checks) { + if (check.conclusion !== "failure") continue; + const timestampValue = check.completedAt ?? check.startedAt ?? timestamp; + items.push({ + id: `transient-check-${check.name}-${timestampValue}`, + prId: args.prId, + source: "unknown", + type: "check_failure", + externalId: `check:${check.name}`, + state: "new", + round: 0, + filePath: null, + line: null, + severity: "major", + headline: `Check failed: ${check.name}`, + body: check.detailsUrl ? `Details: ${check.detailsUrl}` : null, + author: null, + url: check.detailsUrl, + dismissReason: null, + agentSessionId: null, + threadCommentCount: null, + threadLatestCommentId: null, + threadLatestCommentAuthor: null, + threadLatestCommentAt: null, + threadLatestCommentSource: null, + createdAt: timestampValue, + updatedAt: timestampValue, + }); + } + + return items; +} + +function mapInventoryItemView(item: IssueInventoryItem) { + const { + threadLatestCommentId, + threadLatestCommentAuthor, + threadLatestCommentAt, + threadLatestCommentSource, + ...rest + } = item; + return { + ...rest, + latestComment: threadLatestCommentId + ? { + id: threadLatestCommentId, + author: threadLatestCommentAuthor ?? null, + at: threadLatestCommentAt ?? null, + source: threadLatestCommentSource ?? null, + } + : null, + }; +} + +function buildRuntimePatch(input: { + autoConvergeEnabled?: boolean | null; + autoConverge?: boolean | null; + status?: ConvergenceRuntimeState["status"]; + pollerStatus?: ConvergenceRuntimeState["pollerStatus"]; + currentRound?: number | null; + activeSessionId?: string | null; + activeLaneId?: string | null; + activeHref?: string | null; + pauseReason?: string | null; + errorMessage?: string | null; + lastStartedAt?: string | null; + lastPolledAt?: string | null; + lastPausedAt?: string | null; + lastStoppedAt?: string | null; +}): Partial { + const patch: Partial = {}; + const autoConvergeEnabled = input.autoConvergeEnabled ?? input.autoConverge; + if (autoConvergeEnabled != null) patch.autoConvergeEnabled = autoConvergeEnabled; + if (input.currentRound != null) patch.currentRound = input.currentRound; + + const nullableFields = [ + "status", "pollerStatus", "activeSessionId", "activeLaneId", "activeHref", + "pauseReason", "errorMessage", "lastStartedAt", "lastPolledAt", "lastPausedAt", "lastStoppedAt", + ] as const; + for (const key of nullableFields) { + if (input[key] !== undefined) { + (patch as Record)[key] = input[key]; + } + } + return patch; +} + +async function loadPrConvergenceContext( + deps: Pick, + prId: string, +): Promise<{ + pr: PrSummary; + status: Awaited["getStatus"]>>; + runtime: ConvergenceRuntimeState | null; + pipelineSettings: PipelineSettings; + inventory: { items: IssueInventoryItem[]; summary: ConvergenceStatus }; + persistedInventory: boolean; +}> { + if (!deps.prService) throw new Error("PR service is not available."); + const pr = deps.prService.listAll().find((entry) => entry.id === prId) ?? null; + if (!pr) throw new Error(`PR not found: ${prId}`); + + const [status, checks, reviewThreads, comments] = await Promise.all([ + deps.prService.getStatus(prId), + deps.prService.getChecks(prId), + deps.prService.getReviewThreads(prId), + deps.prService.getComments(prId), + ]); + + if (deps.issueInventoryService) { + const snapshot = deps.issueInventoryService.syncFromPrData(prId, checks, reviewThreads, comments); + return { + pr, + status, + runtime: deps.issueInventoryService.getConvergenceRuntime(prId), + pipelineSettings: deps.issueInventoryService.getPipelineSettings(prId), + inventory: { + items: snapshot.items, + summary: snapshot.convergence, + }, + persistedInventory: true, + }; + } + + const items = buildFallbackInventoryItems({ prId, checks, reviewThreads, comments }); + return { + pr, + status, + runtime: null, + pipelineSettings: { ...DEFAULT_PIPELINE_SETTINGS }, + inventory: { + items, + summary: summarizeInventoryItems(items, DEFAULT_PIPELINE_SETTINGS.maxRounds), + }, + persistedInventory: false, + }; +} + export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { const tools: Record = {}; @@ -1135,6 +1380,243 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + const context = await loadPrConvergenceContext(deps, prId); + return { + success: true, + pr: context.pr, + status: context.status, + runtime: context.runtime, + pipelineSettings: context.pipelineSettings, + persistedInventory: context.persistedInventory, + inventory: { + summary: context.inventory.summary, + items: context.inventory.items.map(mapInventoryItemView), + }, + ...(context.runtime?.activeSessionId ? buildNavigationPayload(buildNavigationSuggestion({ + surface: "work", + laneId: context.runtime.activeLaneId ?? context.pr.laneId, + sessionId: context.runtime.activeSessionId, + })) : {}), + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.updatePullRequestConvergencePipeline = tool({ + description: "Edit the persisted PR convergence pipeline settings for auto-merge and round handling.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + autoMerge: z.boolean().optional(), + mergeMethod: z.enum(["merge", "squash", "rebase", "repo_default"]).optional(), + maxRounds: z.number().int().positive().max(20).optional(), + onRebaseNeeded: z.enum(["pause", "auto_rebase"]).optional(), + }), + execute: async ({ prId, autoMerge, mergeMethod, maxRounds, onRebaseNeeded }) => { + if (!deps.issueInventoryService) { + return { success: false, error: "Issue inventory service is not available." }; + } + try { + const patch = Object.fromEntries( + Object.entries({ autoMerge, mergeMethod, maxRounds, onRebaseNeeded }) + .filter(([, v]) => v !== undefined), + ); + if (Object.keys(patch).length === 0) { + return { success: false, error: "No pipeline fields were provided." }; + } + deps.issueInventoryService.savePipelineSettings(prId, patch); + return { + success: true, + prId, + pipelineSettings: deps.issueInventoryService.getPipelineSettings(prId), + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.updatePullRequestConvergenceRuntime = tool({ + description: "Edit the persisted PR convergence runtime object that tracks status, session, and polling state.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + autoConvergeEnabled: z.boolean().optional(), + autoConverge: z.boolean().optional(), + status: z.enum(["idle", "launching", "running", "polling", "paused", "converged", "merged", "failed", "cancelled", "stopped"]).optional(), + pollerStatus: z.enum(["idle", "scheduled", "polling", "waiting_for_checks", "waiting_for_comments", "paused", "stopped"]).optional(), + currentRound: z.number().int().min(0).optional(), + activeSessionId: z.string().nullable().optional(), + activeLaneId: z.string().nullable().optional(), + activeHref: z.string().nullable().optional(), + pauseReason: z.string().nullable().optional(), + errorMessage: z.string().nullable().optional(), + lastStartedAt: z.string().nullable().optional(), + lastPolledAt: z.string().nullable().optional(), + lastPausedAt: z.string().nullable().optional(), + lastStoppedAt: z.string().nullable().optional(), + }), + execute: async (input) => { + if (!deps.issueInventoryService) { + return { success: false, error: "Issue inventory service is not available." }; + } + try { + const patch = buildRuntimePatch(input); + if (Object.keys(patch).length === 0) { + return { success: false, error: "No runtime fields were provided." }; + } + const runtime = deps.issueInventoryService.saveConvergenceRuntime(input.prId, patch); + return { success: true, prId: input.prId, runtime }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.startPullRequestConvergenceRound = tool({ + description: "Launch the next PR convergence round through the existing PR issue-resolution workflow.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + scope: z.enum(["checks", "comments", "both"]).optional().default("both"), + modelId: z.string().trim().min(1).optional(), + reasoning: z.string().nullable().optional(), + permissionMode: z.enum(["read_only", "guarded_edit", "full_edit"]).optional(), + additionalInstructions: z.string().nullable().optional(), + autoConvergeEnabled: z.boolean().optional().default(true), + }), + execute: async ({ prId, scope, modelId, reasoning, permissionMode, additionalInstructions, autoConvergeEnabled }) => { + if (!deps.prService) { + return { success: false, error: "PR service is not available." }; + } + try { + const resolvedModelId = modelId?.trim() || deps.defaultModelId?.trim() || null; + if (!resolvedModelId) { + return { success: false, error: "A modelId is required to launch a convergence round." }; + } + const resolvedReasoning = reasoning ?? deps.defaultReasoningEffort ?? null; + + if (!deps.issueInventoryService) { + return { success: false, error: "Issue inventory service is not available." }; + } + + const result = await launchPrIssueResolutionChat( + { + prService: deps.prService, + laneService: { + list: deps.laneService.list, + getLaneBaseAndBranch: deps.laneService.getLaneBaseAndBranch, + }, + agentChatService: { + createSession: deps.createChat, + sendMessage: deps.sendChatMessage, + previewSessionToolNames: deps.previewSessionToolNames, + }, + sessionService: deps.sessionService, + issueInventoryService: deps.issueInventoryService, + }, + { + prId, + scope, + modelId: resolvedModelId, + reasoning: resolvedReasoning, + permissionMode, + additionalInstructions: additionalInstructions ?? null, + }, + ); + + let runtime: ConvergenceRuntimeState | null = null; + try { + const status = deps.issueInventoryService.getConvergenceStatus(prId); + runtime = deps.issueInventoryService.saveConvergenceRuntime(prId, { + autoConvergeEnabled, + currentRound: status.currentRound, + status: "running", + pollerStatus: "waiting_for_comments", + activeSessionId: result.sessionId, + activeLaneId: result.laneId, + activeHref: result.href, + pauseReason: null, + errorMessage: null, + lastStartedAt: nowIso(), + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + }); + } catch (inventoryError) { + console.error( + `[convergence] Failed to update runtime for PR ${prId}: ${getErrorMessage(inventoryError)}`, + ); + } + + return { + success: true, + prId, + sessionId: result.sessionId, + laneId: result.laneId, + href: result.href, + runtime, + ...buildNavigationPayload(buildNavigationSuggestion({ + surface: "work", + laneId: result.laneId, + sessionId: result.sessionId, + })), + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.stopPullRequestConvergence = tool({ + description: "Stop an active PR convergence run, interrupt the chat session, and persist the stopped runtime state.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + sessionId: z.string().trim().min(1).optional(), + reason: z.string().nullable().optional(), + }), + execute: async ({ prId, sessionId, reason }) => { + try { + const currentRuntime = deps.issueInventoryService?.getConvergenceRuntime(prId) ?? null; + const activeSessionId = sessionId?.trim() || currentRuntime?.activeSessionId || null; + if (!activeSessionId) { + return { success: false, error: "No active convergence session was found to stop." }; + } + await deps.interruptChat({ sessionId: activeSessionId }); + + const stoppedRuntime = currentRuntime?.activeSessionId === activeSessionId + ? deps.issueInventoryService?.saveConvergenceRuntime(prId, { + autoConvergeEnabled: false, + status: "stopped", + pollerStatus: "stopped", + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: reason?.trim() || null, + errorMessage: null, + lastStoppedAt: nowIso(), + }) ?? null + : currentRuntime; + + return { + success: true, + prId, + sessionId: activeSessionId, + runtime: stoppedRuntime, + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + tools.listFileWorkspaces = tool({ description: "List ADE file workspaces so the CTO can inspect files by lane or attached workspace.", inputSchema: z.object({ diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index fb445194a..913b8ca27 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -183,6 +183,45 @@ describe("buildCodingAgentSystemPrompt", () => { }); }); + describe("pull request tools section", () => { + it("includes PR tool guidance when PR workflow tools are present", () => { + const result = buildCodingAgentSystemPrompt({ + cwd: "/x", + toolNames: ["prRefreshIssueInventory", "prGetReviewComments"], + }); + expect(result).toContain("## Pull Request Tools"); + expect(result).toContain("prRefreshIssueInventory, prGetReviewComments"); + expect(result).toContain("not shell commands"); + expect(result).toContain("report the misconfiguration immediately"); + }); + + it("omits PR tool guidance when PR workflow tools are absent", () => { + const result = buildCodingAgentSystemPrompt({ + cwd: "/x", + toolNames: ["readFile", "listFiles"], + }); + expect(result).not.toContain("## Pull Request Tools"); + }); + + it("includes PR tool guidance when ADE MCP PR tools are present", () => { + const result = buildCodingAgentSystemPrompt({ + cwd: "/x", + toolNames: ["pr_refresh_issue_inventory", "pr_get_review_comments"], + }); + expect(result).toContain("## Pull Request Tools"); + expect(result).toContain("pr_refresh_issue_inventory, pr_get_review_comments"); + }); + + it("includes PR tool guidance when namespaced ADE MCP PR tools are present", () => { + const result = buildCodingAgentSystemPrompt({ + cwd: "/x", + toolNames: ["mcp__ade__pr_refresh_issue_inventory", "mcp__ade__pr_get_review_comments"], + }); + expect(result).toContain("## Pull Request Tools"); + expect(result).toContain("mcp__ade__pr_refresh_issue_inventory, mcp__ade__pr_get_review_comments"); + }); + }); + it("always includes operating loop, editing rules, and verification rules", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x" }); expect(result).toContain("## Operating Loop"); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 54074a0c4..798824649 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -47,6 +47,28 @@ export function buildCodingAgentSystemPrompt(args: { const hasCaptureScreenshot = toolNames.includes("captureScreenshot"); const hasReportCompletion = toolNames.includes("reportCompletion"); const hasWorkflowTools = hasCreateLane || hasCreatePr || hasCaptureScreenshot || hasReportCompletion; + const normalizeToolName = (name: string): string => { + const match = name.match(/^mcp__(.+)__(.+)$/); + return match?.[2] ?? name; + }; + const prIssueToolNames = toolNames.filter((name) => { + const normalized = normalizeToolName(name); + return ( + normalized === "prGetChecks" + || normalized === "prGetReviewComments" + || normalized === "prRefreshIssueInventory" + || normalized === "prRerunFailedChecks" + || normalized === "prReplyToReviewThread" + || normalized === "prResolveReviewThread" + || normalized === "pr_get_checks" + || normalized === "pr_get_review_comments" + || normalized === "pr_refresh_issue_inventory" + || normalized === "pr_rerun_failed_checks" + || normalized === "pr_reply_to_review_thread" + || normalized === "pr_resolve_review_thread" + ); + }); + const hasPrIssueTools = prIssueToolNames.length > 0; return [ `You are ADE's software engineering agent working in ${args.cwd}.`, @@ -127,6 +149,17 @@ export function buildCodingAgentSystemPrompt(args: { "**Do not** create infrastructure (CI configs, deployment scripts) or modify settings outside your lane without explicit user approval.", ] : []), + ...(hasPrIssueTools + ? [ + "", + "## Pull Request Tools", + `Key PR tools in this session: ${prIssueToolNames.join(", ")}.`, + "Use these tools first when the task is to address PR comments, review threads, or CI failures.", + "ADE/MCP PR tools are runtime tool calls, not shell commands. Do not probe them with `which`, `command -v`, `.mcp.json`, or local settings files.", + "If the runtime exposes both base and namespaced variants, use the exact identifier shown in the live tool list.", + "If a required PR tool is missing, report the misconfiguration immediately instead of spelunking through local MCP wiring or bootstrap code.", + ] + : []), "", "## Editing Rules", "Prefer existing files and patterns over creating new abstractions.", diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index a91f7d1bf..40c9b7cf3 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -44,7 +44,7 @@ import type { BudgetCapProvider } from "../../../shared/types/usage"; import { buildClaudeReadOnlyWorkerAllowedTools } from "../orchestrator/unifiedOrchestratorAdapter"; import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; import { escapeRegExp, globToRegExp, isRecord, matchesGlob, normalizeSet, nowIso, resolvePathWithinRoot, safeJsonParse } from "../shared/utils"; -import { getDefaultModelDescriptor, getModelById, resolveProviderGroupForModel } from "../../../shared/modelRegistry"; +import { getDefaultModelDescriptor, getModelById, resolveChatProviderForDescriptor, resolveProviderGroupForModel } from "../../../shared/modelRegistry"; type CronTask = { stop: () => void; @@ -1626,7 +1626,8 @@ export function createAutomationService({ throw new Error("No lane is available for this automation run."); } - const { modelId, providerGroup, budgetProvider } = resolveAutomationModelDescriptor(args.rule); + const { modelId, modelDescriptor, providerGroup, budgetProvider } = resolveAutomationModelDescriptor(args.rule); + const resolvedChat = resolveChatProviderForDescriptor(modelDescriptor); const budgetCheck = budgetCapServiceRef?.checkBudget( AUTOMATION_SCOPE as Parameters["checkBudget"]>[0], args.rule.id, @@ -1675,8 +1676,8 @@ export function createAutomationService({ try { const session = await agentChatServiceRef.createSession({ laneId, - provider: "unified", - model: modelId, + provider: resolvedChat.provider, + model: resolvedChat.model, modelId, sessionProfile: "workflow", reasoningEffort, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 48626a0f5..905f67047 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -14,6 +14,7 @@ const mockState = vi.hoisted(() => ({ codexThreadCounter: 0, codexTurnCounter: 0, cursorSessionCounter: 0, + codexRequestPayloads: [] as Array>, codexLineHandler: null as ((line: string) => void) | null, cursorAcquireCalls: [] as Array>, cursorNewSessionCalls: [] as Array>, @@ -46,6 +47,7 @@ vi.mock("node:child_process", () => ({ writable: true, write: vi.fn((line: string) => { const payload = JSON.parse(line); + mockState.codexRequestPayloads.push(payload); if (payload?.id == null || typeof payload?.method !== "string") return true; let result: Record = {}; @@ -109,6 +111,8 @@ vi.mock("ai", () => ({ generateText: vi.fn(), streamText: vi.fn(), stepCountIs: vi.fn(), + tool: vi.fn((def: Record) => def), + jsonSchema: vi.fn((s: unknown) => s), })); vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ @@ -117,6 +121,21 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ unstable_v2_resumeSession: vi.fn(), })); +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => { + const Client = vi.fn().mockImplementation(() => ({ + connect: vi.fn(async () => {}), + listTools: vi.fn(async () => ({ tools: [] })), + callTool: vi.fn(async () => ({ content: [{ type: "text", text: "" }] })), + close: vi.fn(), + })); + return { Client }; +}); + +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => { + const StdioClientTransport = vi.fn().mockImplementation(() => ({})); + return { StdioClientTransport }; +}); + vi.mock("../ai/codexExecutable", () => ({ resolveCodexExecutable: vi.fn(() => ({ path: "codex", source: "fallback-command" })), })); @@ -423,11 +442,99 @@ function createMockProjectConfigService() { } as any; } +function createMockIssueInventoryService() { + const now = new Date().toISOString(); + const runtimeByPr = new Map>(); + + const defaultRuntime = (prId: string) => ({ + prId, + autoConvergeEnabled: false, + status: "idle", + pollerStatus: "idle", + currentRound: 0, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: now, + updatedAt: now, + }); + + return { + syncFromPrData: vi.fn((prId: string) => { + const runtime = { ...defaultRuntime(prId), ...runtimeByPr.get(prId) }; + return { + prId, + items: [], + convergence: { + currentRound: typeof runtime.currentRound === "number" ? runtime.currentRound : 0, + maxRounds: 5, + issuesPerRound: [], + totalNew: 0, + totalFixed: 0, + totalDismissed: 0, + totalEscalated: 0, + totalSentToAgent: 0, + isConverging: false, + canAutoAdvance: false, + }, + runtime, + }; + }), + getInventory: vi.fn(), + getNewItems: vi.fn(() => []), + markSentToAgent: vi.fn(), + markFixed: vi.fn(), + markDismissed: vi.fn(), + markEscalated: vi.fn(), + getConvergenceStatus: vi.fn(() => ({ + currentRound: 0, + maxRounds: 5, + issuesPerRound: [], + totalNew: 0, + totalFixed: 0, + totalDismissed: 0, + totalEscalated: 0, + totalSentToAgent: 0, + isConverging: false, + canAutoAdvance: false, + })), + resetInventory: vi.fn(), + getConvergenceRuntime: vi.fn((prId: string) => ({ + ...defaultRuntime(prId), + ...runtimeByPr.get(prId), + })), + saveConvergenceRuntime: vi.fn((prId: string, state: Record) => { + const existing = runtimeByPr.get(prId) ?? {}; + const merged = { ...defaultRuntime(prId), ...existing, ...state }; + runtimeByPr.set(prId, merged); + return merged; + }), + resetConvergenceRuntime: vi.fn((prId: string) => { + runtimeByPr.delete(prId); + }), + getPipelineSettings: vi.fn(() => ({ + maxRounds: 5, + autoMerge: false, + mergeMethod: "repo_default", + onRebaseNeeded: "pause", + })), + savePipelineSettings: vi.fn(), + deletePipelineSettings: vi.fn(), + } as any; +} + function createService(overrides: Record = {}) { const logger = createLogger(); const laneService = createMockLaneService(); const sessionService = createMockSessionService(); const projectConfigService = createMockProjectConfigService(); + const issueInventoryService = createMockIssueInventoryService(); const transcriptsDir = path.join(tmpRoot, "transcripts"); fs.mkdirSync(transcriptsDir, { recursive: true }); @@ -438,6 +545,7 @@ function createService(overrides: Record = {}) { laneService, sessionService, projectConfigService, + issueInventoryService, logger: logger as any, appVersion: "0.0.1-test", getExternalMcpConfigs: () => [], @@ -445,7 +553,7 @@ function createService(overrides: Record = {}) { ...overrides, }); - return { service, logger, laneService, sessionService, projectConfigService }; + return { service, logger, laneService, sessionService, projectConfigService, issueInventoryService }; } function readPersistedChatState(sessionId: string): Record { @@ -490,6 +598,7 @@ beforeEach(() => { mockState.codexThreadCounter = 0; mockState.codexTurnCounter = 0; mockState.cursorSessionCounter = 0; + mockState.codexRequestPayloads = []; mockState.codexLineHandler = null; mockState.cursorAcquireCalls = []; mockState.cursorNewSessionCalls = []; @@ -669,6 +778,97 @@ describe("createAgentChatService", () => { expect(session.status).toBe("idle"); }); + it("appends ADE tooling guidance to Claude SDK sessions", async () => { + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send: vi.fn(), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-guidance", + } as any); + + const { service } = createService(); + await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(unstable_v2_createSession).toHaveBeenCalled(); + }); + + const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; + expect(opts?.systemPrompt?.append).toContain("ADE and MCP tools are runtime tool calls, not shell commands."); + expect(opts?.systemPrompt?.append).toContain(".mcp.json"); + }); + + it("pre-approves ADE MCP tools for Claude SDK sessions", async () => { + vi.mocked(providerResolver.normalizeCliMcpServers).mockImplementation((_provider, servers) => servers ?? {}); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send: vi.fn(), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-mcp-allow", + } as any); + + const { service } = createService(); + await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(unstable_v2_createSession).toHaveBeenCalled(); + }); + + const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { + allowedTools?: string[]; + mcpServers?: Record>; + } | undefined; + expect(opts?.mcpServers).toHaveProperty("ade"); + expect(opts?.allowedTools).toContain("mcp__ade__*"); + }); + + it("attaches ADE MCP servers through the Claude V2 query controls", async () => { + vi.mocked(providerResolver.normalizeCliMcpServers).mockImplementation((_provider, servers) => servers ?? {}); + const setMcpServers = vi.fn().mockResolvedValue({ + added: ["ade"], + removed: [], + errors: {}, + }); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send: vi.fn(), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-mcp-query", + query: { + setMcpServers, + }, + } as any); + + const { service } = createService(); + await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(setMcpServers).toHaveBeenCalledWith(expect.objectContaining({ + ade: expect.objectContaining({ + command: "node", + }), + })); + }); + }); + it("migrates legacy Claude plan mode into interaction mode", async () => { const { service } = createService(); const session = await service.createSession({ @@ -1166,10 +1366,13 @@ describe("createAgentChatService", () => { expect(secondUserContent).not.toContain("[ADE launch directive]"); }); - it("roots Codex MCP launches in the selected lane worktree", async () => { + it("roots Codex MCP launches in the selected lane worktree while keeping the desktop project root", async () => { const laneRootPath = path.join(tmpRoot, "lane-2"); fs.mkdirSync(laneRootPath, { recursive: true }); const laneRoot = fs.realpathSync(laneRootPath); + // runtimeRoot should always come from the trusted ADE install path + // (resolveUnifiedRuntimeRoot), never from walking up user repo trees. + const runtimeRoot = fs.realpathSync(process.cwd()); vi.mocked(resolveAdeMcpServerLaunch).mockClear(); const { service } = createService(); @@ -1191,9 +1394,19 @@ describe("createAgentChatService", () => { const workspaceRoots = vi.mocked(resolveAdeMcpServerLaunch).mock.calls .map(([args]) => (args as { workspaceRoot?: string }).workspaceRoot) .filter((value): value is string => typeof value === "string"); + const projectRoots = vi.mocked(resolveAdeMcpServerLaunch).mock.calls + .map(([args]) => (args as { projectRoot?: string }).projectRoot) + .filter((value): value is string => typeof value === "string"); + const runtimeRoots = vi.mocked(resolveAdeMcpServerLaunch).mock.calls + .map(([args]) => (args as { runtimeRoot?: string }).runtimeRoot) + .filter((value): value is string => typeof value === "string"); expect(workspaceRoots.length).toBeGreaterThan(0); expect(new Set(workspaceRoots)).toEqual(new Set([laneRoot])); + expect(projectRoots.length).toBeGreaterThan(0); + expect(new Set(projectRoots)).toEqual(new Set([tmpRoot])); + expect(runtimeRoots.length).toBeGreaterThan(0); + expect(new Set(runtimeRoots.map((value) => fs.realpathSync(value)))).toEqual(new Set([runtimeRoot])); }); it("executes identity-hosted unified turns from the selected execution lane", async () => { @@ -2725,6 +2938,63 @@ describe("createAgentChatService", () => { expect(setPermissionMode.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[1]); }); + it("uses Claude V2 query controls for plan mode when the wrapper lacks setPermissionMode", async () => { + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-query-plan", + slash_commands: [], + }; + return; + } + + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Plan via query control" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-query-plan", + query: { + setPermissionMode, + }, + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + interactionMode: "plan", + }); + + const result = await service.runSessionTurn({ + sessionId: session.id, + text: "Outline the implementation only.", + interactionMode: "plan", + }); + + expect(result.outputText).toContain("Plan via query control"); + expect(setPermissionMode).toHaveBeenCalledWith("plan"); + expect(setPermissionMode.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[1]); + }); + it("emits todo_update events for Claude TodoWrite tool uses", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 89031fef9..bbe2bbe39 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -7,10 +7,13 @@ import { generateText, streamText, stepCountIs, + tool as aiTool, + jsonSchema as aiJsonSchema, type FilePart, type ImagePart, type LanguageModel, type ModelMessage, + type Tool as AiTool, type UserContent, } from "ai"; import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk"; @@ -21,7 +24,17 @@ type ClaudeV2Session = { stream: () => AsyncGenerator; close: () => void; readonly sessionId: string; + query?: { + setMcpServers?: (servers: Record>) => Promise<{ + added?: string[]; + removed?: string[]; + errors?: Record; + }>; + setPermissionMode?: (mode: AgentChatClaudePermissionMode) => Promise; + supportedCommands?: () => Promise>; + }; setPermissionMode?: (mode: AgentChatClaudePermissionMode) => Promise; + supportedCommands?: () => Promise>; }; import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message"; import { @@ -117,6 +130,7 @@ import { import { canSwitchChatSessionModel } from "../../../shared/chatModelSwitching"; import { detectAllAuth } from "../ai/authDetector"; import * as providerResolver from "../ai/providerResolver"; +import { buildCodexAppServerMcpConfigOverrides } from "../ai/codexAppServerConfig"; import { createUniversalToolSet, type PermissionMode } from "../ai/tools/universalTools"; import { createWorkflowTools } from "../ai/tools/workflowTools"; import { createLinearTools } from "../ai/tools/linearTools"; @@ -141,10 +155,13 @@ import type { createLinearDispatcherService } from "../cto/linearDispatcherServi import type { LinearClient } from "../cto/linearClient"; import type { LinearCredentialService } from "../cto/linearCredentialService"; import type { createPrService } from "../prs/prService"; +import type { createIssueInventoryService } from "../prs/issueInventoryService"; import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { createProofObserver } from "../computerUse/proofObserver"; import { maybeSyntheticToolResult } from "../computerUse/syntheticToolResult"; import { resolveAdeMcpServerLaunch, resolveUnifiedRuntimeRoot } from "../orchestrator/unifiedOrchestratorAdapter"; +import { Client as McpSdkClient } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport as McpStdioTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import type { McpServer, PermissionOption, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"; import type { ExternalMcpServerConfig } from "../../../shared/types/externalMcp"; import { resolveCursorAgentExecutable } from "../ai/cursorAgentExecutable"; @@ -329,6 +346,9 @@ type UnifiedRuntime = { interrupted: boolean; resolvedModel: LanguageModel; modelDescriptor: ModelDescriptor; + /** MCP client connected to the ADE MCP server via stdio. */ + mcpClient: McpSdkClient | null; + mcpTransport: McpStdioTransport | null; }; type CursorPermissionWaiter = { @@ -532,6 +552,13 @@ function isSignalPermissionError(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "EPERM"); } +function isAbortRelatedError(error: unknown): boolean { + if (typeof globalThis.DOMException === "function" && error instanceof globalThis.DOMException && error.name === "AbortError") return true; + if (error instanceof Error && error.name === "AbortError") return true; + const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); + return message.includes("aborterror") || message.includes("aborted by user"); +} + function isProcessAlive(pid: number | null): boolean { if (pid == null || !Number.isInteger(pid) || pid <= 0) return false; try { @@ -679,6 +706,7 @@ type ManagedChatSession = { preferredExecutionLaneId: string | null; selectedExecutionLaneId: string | null; lastLaneDirectiveKey: string | null; + runtimeInvalidated: boolean; localPendingInputs: Map) return session.sessionProfile === "light"; } -let _mcpRuntimeRootCache: string | null = null; function resolveMcpRuntimeRoot(): string { - _mcpRuntimeRootCache ??= resolveUnifiedRuntimeRoot(); - return _mcpRuntimeRootCache; + // Only use the trusted ADE install path — never walk up user repo trees + // which could match apps/mcp-server/package.json by coincidence. + return resolveUnifiedRuntimeRoot(); } @@ -2087,6 +2115,7 @@ export function createAgentChatService(args: { linearClient?: LinearClient | null; linearCredentials?: LinearCredentialService | null; prService?: ReturnType | null; + issueInventoryService: ReturnType; processService?: ReturnType | null; getTestService?: () => { listSuites: () => any[]; run: (args: any) => Promise; stop: (args: any) => void; listRuns: (args?: any) => any[]; getLogTail: (args: any) => string } | null; ptyService?: { create: (args: any) => Promise<{ ptyId: string; sessionId: string }> } | null; @@ -2120,6 +2149,7 @@ export function createAgentChatService(args: { linearClient: linearClientRef, linearCredentials: linearCredentialsRef, prService, + issueInventoryService, processService, getTestService, ptyService, @@ -2142,6 +2172,9 @@ export function createAgentChatService(args: { if (!getDirtyFileTextForPath) { throw new Error("createAgentChatService: getDirtyFileTextForPath is required"); } + if (!issueInventoryService) { + throw new Error("Issue inventory service is required to initialize agent chat."); + } let computerUseArtifactBrokerRef = computerUseArtifactBrokerService ?? null; @@ -2710,6 +2743,91 @@ export function createAgentChatService(args: { return Array.from(snapshots.values()); }; + const previewSessionToolNames = ({ + laneId, + sessionProfile, + identityKey, + computerUse, + }: Pick): string[] => { + const effectiveSessionProfile = sessionProfile ?? "workflow"; + if (effectiveSessionProfile === "light") return []; + + const sessionId = `preview:${laneId}`; + const toolNames = new Set(); + const workflowTools = createWorkflowTools({ + laneService, + prService: prService ?? undefined, + computerUseArtifactBrokerService: computerUseArtifactBrokerRef ?? undefined, + computerUsePolicy: computerUse, + onReportCompletion: null, + sessionId, + laneId, + }); + for (const toolName of Object.keys(workflowTools)) { + toolNames.add(toolName); + } + + const linearTools = createLinearTools({ + linearClient: linearClientRef ?? null, + credentials: linearCredentialsRef ?? null, + }); + for (const toolName of Object.keys(linearTools)) { + toolNames.add(toolName); + } + + if (identityKey === "cto") { + const ctoTools = createCtoOperatorTools({ + currentSessionId: sessionId, + defaultLaneId: laneId, + defaultModelId: null, + defaultReasoningEffort: null, + resolveExecutionLane: async ({ requestedLaneId }) => requestedLaneId?.trim() || laneId, + laneService, + missionService: getMissionService?.() ?? null, + aiOrchestratorService: getAiOrchestratorService?.() ?? null, + workerAgentService: workerAgentService ?? null, + workerHeartbeatService: workerHeartbeatService ?? null, + linearDispatcherService: getLinearDispatcherService?.() ?? null, + flowPolicyService: flowPolicyService ?? null, + prService: prService ?? null, + issueInventoryService, + fileService: fileService ?? null, + processService: processService ?? null, + testService: getTestService?.() ?? null, + ptyService: ptyService ?? null, + automationService: getAutomationService?.() ?? null, + issueTracker: linearIssueTracker ?? null, + listChats: listSessions, + getChatStatus: getSessionSummary, + getChatTranscript, + createChat: createSession, + updateChatSession: updateSession, + sendChatMessage: sendMessage, + interruptChat: interrupt, + resumeChat: resumeSession, + disposeChat: dispose, + sessionService, + ensureCtoSession: async ({ laneId: requestedLaneId, modelId, reasoningEffort, reuseExisting }) => + ensureIdentitySession({ + identityKey: "cto", + laneId: requestedLaneId, + modelId, + reasoningEffort, + reuseExisting, + permissionMode: "full-auto", + }), + previewSessionToolNames, + } as Parameters[0] & { + previewSessionToolNames: typeof previewSessionToolNames; + }); + for (const toolName of Object.keys(ctoTools)) { + toolNames.add(toolName); + } + } + + return Array.from(toolNames).sort((a, b) => a.localeCompare(b)); + }; + const deriveSessionCapabilities = (managed: ManagedChatSession | null): AgentChatSessionCapabilities => ({ supportsSubagentInspection: Boolean(managed && (managed.session.provider === "claude" || managed.session.provider === "codex")), supportsSubagentControl: Boolean(managed && managed.runtime?.kind === "claude"), @@ -2724,19 +2842,31 @@ export function createAgentChatService(args: { chatSessionId?: string | null, computerUsePolicy?: ComputerUsePolicy | null, ): Record> => { + // CLI providers (claude/codex) spawn the MCP server as a plain Node child + // process via stdio. Skip the Electron bundled-proxy so the command + // resolves to `node dist/index.cjs` which the CLI can manage as stdio. const launch = resolveAdeMcpServerLaunch({ + projectRoot, workspaceRoot, runtimeRoot: resolveMcpRuntimeRoot(), defaultRole, ownerId: ownerId ?? undefined, chatSessionId: chatSessionId ?? undefined, computerUsePolicy: normalizeComputerUsePolicy(computerUsePolicy, createDefaultComputerUsePolicy()), + preferBundledProxy: false, }); return providerResolver.normalizeCliMcpServers(provider, { ade: { command: launch.command, args: launch.cmdArgs, - env: launch.env + env: launch.env, + ...(provider === "codex" + ? { + required: true, + startup_timeout_sec: 30, + tool_timeout_sec: 120, + } + : {}), } }) ?? {}; }; @@ -2765,6 +2895,213 @@ export function createAgentChatService(args: { return list; }; + const getClaudeV2SessionControl = ( + session: ClaudeV2Session | null | undefined, + ): { + setMcpServers?: (servers: Record>) => Promise<{ + added?: string[]; + removed?: string[]; + errors?: Record; + }>; + setPermissionMode?: (mode: AgentChatClaudePermissionMode) => Promise; + supportedCommands?: () => Promise>; + } => { + const sessionRecord = session as (ClaudeV2Session & { query?: ClaudeV2Session["query"] }) | null | undefined; + const query = sessionRecord?.query; + + return { + setMcpServers: typeof query?.setMcpServers === "function" ? query.setMcpServers.bind(query) : undefined, + setPermissionMode: typeof sessionRecord?.setPermissionMode === "function" + ? sessionRecord.setPermissionMode.bind(sessionRecord) + : (typeof query?.setPermissionMode === "function" ? query.setPermissionMode.bind(query) : undefined), + supportedCommands: typeof sessionRecord?.supportedCommands === "function" + ? sessionRecord.supportedCommands.bind(sessionRecord) + : (typeof query?.supportedCommands === "function" ? query.supportedCommands.bind(query) : undefined), + }; + }; + + const attachClaudeV2McpServers = async ( + managed: ManagedChatSession, + session: ClaudeV2Session | null | undefined, + mcpServers: Record> | undefined, + ): Promise => { + if (!mcpServers || Object.keys(mcpServers).length === 0) return; + + const control = getClaudeV2SessionControl(session); + if (typeof control.setMcpServers !== "function") { + logger.warn("agent_chat.claude_v2_mcp_attach_unavailable", { + sessionId: managed.session.id, + serverNames: Object.keys(mcpServers), + }); + return; + } + + try { + const result = await control.setMcpServers(mcpServers); + const errors = Object.entries(result?.errors ?? {}).filter(([, message]) => typeof message === "string" && message.trim().length > 0); + if (errors.length > 0) { + logger.warn("agent_chat.claude_v2_mcp_attach_failed", { + sessionId: managed.session.id, + errors: Object.fromEntries(errors), + }); + return; + } + logger.info("agent_chat.claude_v2_mcp_attach", { + sessionId: managed.session.id, + added: result?.added ?? [], + removed: result?.removed ?? [], + }); + } catch (error) { + logger.warn("agent_chat.claude_v2_mcp_attach_failed", { + sessionId: managed.session.id, + error, + }); + } + }; + + const buildClaudeAllowedTools = ( + mcpServers: Record> | undefined, + ): string[] => Object.keys(mcpServers ?? {}) + .map((serverName) => serverName.trim()) + .filter((serverName) => serverName.length > 0) + .map((serverName) => `mcp__${serverName}__*`); + + /** + * Spawn the ADE MCP server as a stdio child process and connect an MCP SDK + * client. Returns the client + transport so the caller can list/call tools + * and close the connection when the session is disposed. + */ + const spawnUnifiedMcpClient = async ( + workspaceRoot: string, + defaultRole: "agent" | "cto", + ownerId?: string | null, + chatSessionId?: string | null, + computerUsePolicy?: ComputerUsePolicy | null, + ): Promise<{ client: McpSdkClient; transport: McpStdioTransport }> => { + const launch = resolveAdeMcpServerLaunch({ + projectRoot, + workspaceRoot, + runtimeRoot: resolveMcpRuntimeRoot(), + defaultRole, + ownerId: ownerId ?? undefined, + chatSessionId: chatSessionId ?? undefined, + computerUsePolicy: normalizeComputerUsePolicy(computerUsePolicy, createDefaultComputerUsePolicy()), + preferBundledProxy: false, + }); + + const mergedEnv: Record = {}; + for (const [k, v] of Object.entries({ ...process.env, ...launch.env })) { + if (v !== undefined) mergedEnv[k] = v; + } + + const transport = new McpStdioTransport({ + command: launch.command, + args: launch.cmdArgs, + env: mergedEnv, + }); + + const client = new McpSdkClient( + { name: "ade-unified-chat", version: "1.0.0" }, + { capabilities: {} }, + ); + + await client.connect(transport); + return { client, transport }; + }; + + /** MCP tool names that are purely read-only and never need approval or memory-orientation gating. */ + const MCP_READ_ONLY_PREFIX_RE = /^(?:get_|read_|search_|list_|memory_search)/; + + /** + * Discover tools from an MCP client and convert each into an AI SDK `tool()` + * so they can be merged with the universal tool set and passed to `streamText()`. + * + * When `guards` are provided the execute path enforces the same + * memory-orientation and plan/edit approval checks that in-process + * universal tools use, preventing MCP tools from bypassing those gates. + */ + const buildMcpToolWrappers = async ( + mcpClient: McpSdkClient, + guards?: { + permissionMode: PermissionMode; + turnMemoryPolicyState?: TurnMemoryPolicyState; + onApprovalRequest?: (request: { + category: "write" | "bash"; + description: string; + detail?: unknown; + }) => Promise<{ approved: boolean; decision?: AgentChatApprovalDecision; reason?: string | null }>; + }, + ): Promise> => { + const { tools: mcpTools } = await mcpClient.listTools(); + const wrapped: Record = {}; + const resolveMcpToolTimeoutMs = (): number => { + const rawTimeout = process.env.ADE_MCP_TOOL_CALL_TIMEOUT_MS ?? process.env.ADE_MCP_STEP_TIMEOUT_MS; + const parsedTimeout = rawTimeout ? Number(rawTimeout) : NaN; + return Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? Math.floor(parsedTimeout) : 30_000; + }; + + for (const spec of mcpTools) { + const toolName = `mcp__ade__${spec.name}`; + const isReadOnly = MCP_READ_ONLY_PREFIX_RE.test(spec.name); + wrapped[toolName] = aiTool({ + description: spec.description ?? spec.name, + inputSchema: aiJsonSchema(spec.inputSchema as any), + execute: async (args) => { + // ── Guard: memory orientation ── + if (guards?.turnMemoryPolicyState && !isReadOnly) { + const mps = guards.turnMemoryPolicyState; + if (mps.classification === "required" && !mps.orientationSatisfied && !mps.explicitSearchPerformed) { + return "EXECUTION DENIED: Search memory before mutating files or running mutating commands for this turn."; + } + } + + // ── Guard: plan/edit approval ── + if (guards && !isReadOnly) { + const mode = guards.permissionMode; + const needsApproval = mode === "plan" || mode === "edit"; + if (needsApproval && guards.onApprovalRequest) { + try { + const result = await guards.onApprovalRequest({ + category: "write", + description: `MCP tool: ${spec.name}`, + detail: { tool: spec.name, arguments: args }, + }); + if (!result.approved) { + return `EXECUTION DENIED: ${result.reason ?? "MCP tool call was not approved."}`; + } + } catch (err) { + return `EXECUTION DENIED: Approval request failed — ${err instanceof Error ? err.message : String(err)}`; + } + } + } + + const timeoutMs = resolveMcpToolTimeoutMs(); + let timeoutId: ReturnType | null = null; + const callToolPromise = mcpClient.callTool({ name: spec.name, arguments: args as Record }); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`MCP tool '${spec.name}' timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + }); + try { + const result = await Promise.race([callToolPromise, timeoutPromise]); + // MCP tools return { content: [{ type, text }] } — flatten to text. + const content = result.content; + if (Array.isArray(content)) { + return content + .map((c: any) => (typeof c === "string" ? c : c?.text ?? JSON.stringify(c))) + .join("\n"); + } + return typeof content === "string" ? content : JSON.stringify(content); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + }, + }); + } + return wrapped; + }; + const summarizeAdeMcpLaunch = (args: { workspaceRoot: string; defaultRole: "agent" | "cto" | "external"; @@ -2772,6 +3109,7 @@ export function createAgentChatService(args: { computerUsePolicy?: ComputerUsePolicy | null; }) => { const { mode, command, entryPath, runtimeRoot, socketPath, packaged, resourcesPath } = resolveAdeMcpServerLaunch({ + projectRoot, workspaceRoot: args.workspaceRoot, runtimeRoot: resolveMcpRuntimeRoot(), defaultRole: args.defaultRole, @@ -3539,6 +3877,33 @@ export function createAgentChatService(args: { chatConfig.unifiedPermissionMode, ); + // Spawn the ADE MCP server so unified sessions get the same tools as + // Claude / Codex sessions. If the MCP server fails to start we fall + // back gracefully — the session still works with in-process tools. + let mcpClient: McpSdkClient | null = null; + let mcpTransport: McpStdioTransport | null = null; + if (!isLightweightSession(managed.session)) { + try { + const mcp = await spawnUnifiedMcpClient( + managed.laneWorktreePath, + managed.session.identityKey === "cto" ? "cto" : "agent", + resolveWorkerIdentityAgentId(managed.session.identityKey), + managed.session.id, + managed.session.computerUse, + ); + mcpClient = mcp.client; + mcpTransport = mcp.transport; + logger.info("agent_chat.unified_mcp_connected", { + sessionId: managed.session.id, + }); + } catch (error) { + logger.warn("agent_chat.unified_mcp_spawn_failed", { + sessionId: managed.session.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + const runtime: UnifiedRuntime = { kind: "unified", messages: [], @@ -3552,13 +3917,16 @@ export function createAgentChatService(args: { interrupted: false, resolvedModel, modelDescriptor: descriptor, + mcpClient, + mcpTransport, }; managed.runtime = runtime; + managed.runtimeInvalidated = false; managed.session.provider = "unified"; managed.session.unifiedPermissionMode = permMode; managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; - managed.session.capabilityMode = "fallback"; + managed.session.capabilityMode = mcpClient ? "full_mcp" : "fallback"; return "handled"; }; @@ -3727,6 +4095,15 @@ export function createAgentChatService(args: { const metadataPathFor = (sessionId: string): string => path.join(chatSessionsDir, `${sessionId}.json`); const persistChatState = (managed: ManagedChatSession): void => { + // When runtime has been torn down (null) but NOT intentionally invalidated, + // fall back to the last persisted state so that sdkSessionId, messages, and + // lastLaneDirectiveKey survive a transient teardown (e.g. app backgrounding). + // When runtimeInvalidated is set, teardownRuntime() intentionally cleared + // runtime state, so we must NOT restore stale values from disk. + let prevPersisted: PersistedChatState | null = null; + if (!managed.runtime && !managed.runtimeInvalidated) { + try { prevPersisted = readPersistedState(managed.session.id); } catch { /* ignore */ } + } const payload: PersistedChatState = { version: 2, sessionId: managed.session.id, @@ -3758,13 +4135,15 @@ export function createAgentChatService(args: { ...(managed.runtime?.kind === "cursor" && managed.runtime.acpSessionId ? { acpSessionId: managed.runtime.acpSessionId } : {}), - ...(managed.runtime?.kind === "claude" ? { sdkSessionId: managed.runtime.sdkSessionId ?? undefined } : {}), + ...(managed.runtime?.kind === "claude" + ? { sdkSessionId: managed.runtime.sdkSessionId ?? undefined } + : prevPersisted?.sdkSessionId ? { sdkSessionId: prevPersisted.sdkSessionId } : {}), ...(managed.runtime?.kind === "claude" && managed.runtime.approvalOverrides.size > 0 ? { approvalOverrides: [...managed.runtime.approvalOverrides] } - : {}), + : prevPersisted?.approvalOverrides?.length ? { approvalOverrides: prevPersisted.approvalOverrides } : {}), ...(managed.runtime?.kind === "unified" ? { messages: managed.runtime.messages.map((m) => ({ role: m.role as "user" | "assistant", content: m.content })) } - : {}), + : prevPersisted?.messages?.length ? { messages: prevPersisted.messages } : {}), ...(managed.recentConversationEntries.length ? { recentConversationEntries: managed.recentConversationEntries.map((entry) => ({ @@ -3778,7 +4157,9 @@ export function createAgentChatService(args: { ...(managed.continuitySummaryUpdatedAt ? { continuitySummaryUpdatedAt: managed.continuitySummaryUpdatedAt } : {}), ...(managed.preferredExecutionLaneId ? { preferredExecutionLaneId: managed.preferredExecutionLaneId } : {}), ...(managed.selectedExecutionLaneId ? { selectedExecutionLaneId: managed.selectedExecutionLaneId } : {}), - ...(managed.lastLaneDirectiveKey ? { lastLaneDirectiveKey: managed.lastLaneDirectiveKey } : {}), + ...(managed.lastLaneDirectiveKey + ? { lastLaneDirectiveKey: managed.lastLaneDirectiveKey } + : prevPersisted?.lastLaneDirectiveKey ? { lastLaneDirectiveKey: prevPersisted.lastLaneDirectiveKey } : {}), manuallyNamed: Boolean(managed.manuallyNamed) || (() => { const trimmedTitle = String(sessionService.get(managed.session.id)?.title || "").trim(); @@ -4481,6 +4862,8 @@ export function createAgentChatService(args: { managed.runtime = null; } if (managed.runtime?.kind === "claude") { + // Mark interrupted so the streaming catch block takes the graceful path + managed.runtime.interrupted = true; cancelClaudeWarmup(managed, managed.runtime, "teardown"); managed.runtime.activeQuery?.close(); managed.runtime.activeQuery = null; @@ -4496,11 +4879,16 @@ export function createAgentChatService(args: { managed.runtime = null; } if (managed.runtime?.kind === "unified") { + // Mark interrupted so the streaming catch block takes the graceful path + managed.runtime.interrupted = true; managed.runtime.abortController?.abort(); for (const pending of managed.runtime.pendingApprovals.values()) { pending.resolve({ decision: "cancel" }); } managed.runtime.pendingApprovals.clear(); + // Tear down MCP client + transport + try { managed.runtime.mcpClient?.close(); } catch { /* ignore */ } + try { managed.runtime.mcpTransport?.close(); } catch { /* ignore */ } managed.runtime = null; } if (managed.runtime?.kind === "cursor") { @@ -4516,6 +4904,7 @@ export function createAgentChatService(args: { if (rt.pooled) releaseCursorAcpConnection(rt.poolKey); managed.runtime = null; } + managed.runtimeInvalidated = true; clearLaneDirectiveKey(managed); }; @@ -4790,6 +5179,7 @@ export function createAgentChatService(args: { preferredExecutionLaneId: persisted?.preferredExecutionLaneId ?? null, selectedExecutionLaneId: persisted?.selectedExecutionLaneId ?? null, lastLaneDirectiveKey: persisted?.lastLaneDirectiveKey ?? null, + runtimeInvalidated: false, activeAssistantMessageId: null, lastActivitySignature: null, bufferedReasoning: null, @@ -5043,6 +5433,13 @@ export function createAgentChatService(args: { }; } + if (isAbortRelatedError(error)) { + return { + message: "Session was interrupted.", + errorInfo: { category: "unknown", provider: providerFamily, model: modelDisplayName }, + }; + } + return { message: rawMessage, errorInfo: { category: "unknown", provider: providerFamily, model: modelDisplayName }, @@ -5245,6 +5642,11 @@ export function createAgentChatService(args: { } else { runtime.v2Session = unstable_v2_createSession(v2Opts as any) as unknown as ClaudeV2Session; } + await attachClaudeV2McpServers( + managed, + runtime.v2Session, + v2Opts.mcpServers as Record> | undefined, + ); } // Build the message — plain string for text-only, or SDKUserMessage with @@ -5254,8 +5656,24 @@ export function createAgentChatService(args: { }); const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); - if (typeof runtime.v2Session.setPermissionMode === "function") { - await runtime.v2Session.setPermissionMode(turnPermissionMode); + const sessionControl = getClaudeV2SessionControl(runtime.v2Session); + if (typeof sessionControl.setPermissionMode === "function") { + try { + await sessionControl.setPermissionMode(turnPermissionMode); + } catch (permErr) { + // Invalidate the V2 session so it is recreated with the correct + // mode, then rethrow so the turn follows the normal failure path. + logger.warn("agent_chat.v2_set_permission_mode_failed", { + sessionId: managed.session.id, + turnPermissionMode, + error: String(permErr), + }); + cancelClaudeWarmup(managed, runtime, "session_reset"); + try { runtime.v2Session?.close(); } catch { /* ignore */ } + runtime.v2Session = null; + runtime.v2WarmupDone = null; + throw new Error(`Permission mode change to '${turnPermissionMode}' was rejected by the SDK. The session will be recreated on the next attempt.`); + } } else if (turnPermissionMode === "plan") { throw new Error("Claude plan mode is not available in this Claude SDK build."); } @@ -5291,9 +5709,9 @@ export function createAgentChatService(args: { applyClaudeSlashCommands(runtime, initMsg.slash_commands); } try { - const sessionImpl = runtime.v2Session as any; - if (typeof sessionImpl?.supportedCommands === "function") { - sessionImpl.supportedCommands().then((cmds: any[]) => { + const control = getClaudeV2SessionControl(runtime.v2Session); + if (typeof control.supportedCommands === "function") { + control.supportedCommands().then((cmds: any[]) => { if (Array.isArray(cmds) && cmds.length > 0) { applyClaudeSlashCommands(runtime, cmds); } @@ -5899,6 +6317,17 @@ export function createAgentChatService(args: { status: "interrupted", ...doneModel, }); + } else if (isAbortRelatedError(error)) { + // System-triggered abort (dispose/teardown) that wasn't flagged as interrupted. + // Treat as interruption to avoid surfacing raw SDK messages like "aborted by user". + managed.session.status = "idle"; + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + emitChatEvent(managed, { + type: "done", + turnId, + status: "interrupted", + ...doneModel, + }); } else { managed.session.status = "idle"; const isAuthFailure = isClaudeRuntimeAuthError(error); @@ -5936,6 +6365,7 @@ export function createAgentChatService(args: { error: error instanceof Error ? error.message : String(error), }); runtime.sdkSessionId = null; + managed.runtimeInvalidated = true; clearLaneDirectiveKey(managed); void maybeRefreshIdentityContinuitySummary(managed, "provider_reset"); refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); @@ -6302,6 +6732,7 @@ export function createAgentChatService(args: { linearDispatcherService: getLinearDispatcherService?.() ?? null, flowPolicyService: flowPolicyService ?? null, prService: prService ?? null, + issueInventoryService, fileService: fileService ?? null, processService: processService ?? null, testService: getTestService?.() ?? null, @@ -6317,6 +6748,7 @@ export function createAgentChatService(args: { interruptChat: interrupt, resumeChat: resumeSession, disposeChat: dispose, + sessionService, ensureCtoSession: async ({ laneId, modelId, reasoningEffort, reuseExisting }) => ensureIdentitySession({ identityKey: "cto", @@ -6326,10 +6758,75 @@ export function createAgentChatService(args: { reuseExisting, permissionMode: "full-auto", }), + previewSessionToolNames, + } as Parameters[0] & { + previewSessionToolNames: typeof previewSessionToolNames; })); } } + // Merge MCP tools from the ADE MCP server so unified sessions have the + // same tooling surface as Claude / Codex sessions. + if (!lightweight && runtime.mcpClient) { + try { + const mcpTools = await buildMcpToolWrappers(runtime.mcpClient, { + permissionMode: runtime.permissionMode, + turnMemoryPolicyState, + onApprovalRequest: async ({ category, description, detail }) => { + if (runtime.approvalOverrides.has(category as any)) { + return { approved: true, decision: "accept_for_session" as const, reason: "Already approved for this session." }; + } + + const approvalItemId = randomUUID(); + const request: PendingInputRequest = { + requestId: approvalItemId, + itemId: approvalItemId, + source: "unified", + kind: "approval", + description, + questions: [], + allowsFreeform: false, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { category, detail }, + turnId, + }; + emitPendingInputRequest(managed, request, { + kind: "file_change", + description, + detail: detail && typeof detail === "object" && !Array.isArray(detail) + ? { ...(detail as Record) } + : {}, + }); + + const response = await new Promise<{ decision?: AgentChatApprovalDecision; responseText?: string | null }>((resolve) => { + runtime.pendingApprovals.set(approvalItemId, { category: category as any, request, resolve }); + }); + runtime.pendingApprovals.delete(approvalItemId); + + if (response.decision === "accept_for_session") { + runtime.approvalOverrides.add(category as any); + } + + const approved = response.decision === "accept" || response.decision === "accept_for_session"; + const trimmedReason = typeof response.responseText === "string" ? response.responseText.trim() : ""; + return { + approved, + decision: response.decision, + reason: trimmedReason.length ? trimmedReason : approved ? "User approved the action." : "User denied the action.", + }; + }, + }); + Object.assign(tools, mcpTools); + } catch (error) { + logger.warn("agent_chat.unified_mcp_tools_failed", { + sessionId: managed.session.id, + turnId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + const thinkingLevel = mapReasoningEffortToThinking(managed.session.reasoningEffort); const providerOptions = providerResolver.buildProviderOptions(runtime.modelDescriptor, thinkingLevel); const baseHarnessPrompt = lightweight @@ -6604,6 +7101,17 @@ export function createAgentChatService(args: { model: managed.session.model, ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), }); + } else if (isAbortRelatedError(error)) { + // System-triggered abort (dispose/teardown) that wasn't flagged as interrupted. + managed.session.status = "idle"; + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + emitChatEvent(managed, { + type: "done", + turnId, + status: "interrupted", + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); } else { managed.session.status = "idle"; @@ -7852,6 +8360,7 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "codex") return managed.runtime; const runtime = await startCodexRuntime(managed); managed.runtime = runtime; + managed.runtimeInvalidated = false; return runtime; }; @@ -7900,10 +8409,12 @@ export function createAgentChatService(args: { codexPolicy: CodexPolicy, mcpServers: Record>, ): Promise => { + const mcpConfig = buildCodexAppServerMcpConfigOverrides(mcpServers); const startResponse = await runtime.request<{ thread?: { id?: string } }>("thread/start", { model: managed.session.model, ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), cwd: managed.laneWorktreePath, + ...(mcpConfig ? { config: mcpConfig } : {}), mcpServers, mcp_servers: mcpServers, ...codexPolicyArgs(codexPolicy), @@ -7962,6 +8473,7 @@ export function createAgentChatService(args: { const opts: ClaudeSDKOptions = { cwd: managed.laneWorktreePath, permissionMode: claudePermissionMode as any, + ...(claudePermissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } as any : {}), includePartialMessages: true, agentProgressSummaries: true, promptSuggestions: true, @@ -7984,6 +8496,11 @@ export function createAgentChatService(args: { "**Write sparingly and well:** Only save knowledge a developer joining this project would find useful on their first day. Each memory should be a single actionable insight.", "GOOD memories: \"Convention: always use snake_case for DB columns\", \"Decision: chose Postgres over Mongo for ACID transactions\", \"Pitfall: CI silently skips tests if file doesn't match *.test.ts\"", "DO NOT save: file paths, raw error messages without lessons, task progress updates, information derivable from git log or the code itself, obvious patterns already visible in the codebase.", + "", + "## ADE Tooling", + "ADE and MCP tools are runtime tool calls, not shell commands.", + "Do not probe tool availability with `which`, `command -v`, `.mcp.json`, or project settings files.", + "Use the exact tool identifier exposed in this session's tool list. MCP-backed ADE tools may appear in namespaced form like `mcp__ade__pr_refresh_issue_inventory`.", ].join("\n"), }; opts.settingSources = ["user", "project", "local"]; @@ -7995,12 +8512,16 @@ export function createAgentChatService(args: { managed.session.id, managed.session.computerUse, ) as any; + const allowedTools = buildClaudeAllowedTools(opts.mcpServers as Record> | undefined); + if (allowedTools.length > 0) { + opts.allowedTools = allowedTools; + } opts.canUseTool = buildClaudeCanUseTool(runtime, managed) as any; // Handle MCP elicitation requests (form input or OAuth URL flows). (opts as any).onElicitation = async ( elicitReq: { serverName: string; message: string; mode?: "form" | "url"; url?: string; elicitationId?: string; requestedSchema?: Record }, - elicitOpts: { signal: AbortSignal }, + _elicitOpts: { signal: AbortSignal }, ): Promise<{ action: "accept" | "decline" | "cancel"; content?: Record }> => { const approvalItemId = randomUUID(); const turnId = runtime.activeTurnId ?? undefined; @@ -8452,6 +8973,11 @@ export function createAgentChatService(args: { } else { runtime.v2Session = unstable_v2_createSession(v2Opts as any) as unknown as ClaudeV2Session; } + await attachClaudeV2McpServers( + managed, + runtime.v2Session, + v2Opts.mcpServers as Record> | undefined, + ); if (runtime.v2WarmupCancelled) { try { runtime.v2Session?.close(); } catch { /* ignore */ } @@ -8462,8 +8988,9 @@ export function createAgentChatService(args: { // Apply permission mode before the first interaction so the session // starts with the correct approval behaviour selected in the rebase tab. const initialPermissionMode = resolveClaudeTurnPermissionMode(managed); - if (typeof runtime.v2Session.setPermissionMode === "function") { - await runtime.v2Session.setPermissionMode(initialPermissionMode); + const sessionControl = getClaudeV2SessionControl(runtime.v2Session); + if (typeof sessionControl.setPermissionMode === "function") { + await sessionControl.setPermissionMode(initialPermissionMode); } await runtime.v2Session.send("System initialization check. Respond with only the word READY."); @@ -8479,9 +9006,9 @@ export function createAgentChatService(args: { applyClaudeSlashCommands(runtime, initMsg.slash_commands); } try { - const sessionImpl = runtime.v2Session as any; - if (typeof sessionImpl?.supportedCommands === "function") { - sessionImpl.supportedCommands().then((cmds: any[]) => { + const control = getClaudeV2SessionControl(runtime.v2Session); + if (typeof control.supportedCommands === "function") { + control.supportedCommands().then((cmds: any[]) => { if (Array.isArray(cmds) && cmds.length > 0) { applyClaudeSlashCommands(runtime, cmds); } @@ -8590,6 +9117,7 @@ export function createAgentChatService(args: { pendingElicitations: new Map void>(), }; managed.runtime = runtime; + managed.runtimeInvalidated = false; return runtime; }; @@ -8630,6 +9158,7 @@ export function createAgentChatService(args: { preferredExecutionLaneId: null, selectedExecutionLaneId: null, lastLaneDirectiveKey: null, + runtimeInvalidated: false, activeAssistantMessageId: null, previewTextBuffer: null, bufferedText: null, @@ -8970,6 +9499,7 @@ export function createAgentChatService(args: { preferredExecutionLaneId: null, selectedExecutionLaneId: null, lastLaneDirectiveKey: null, + runtimeInvalidated: false, activeAssistantMessageId: null, lastActivitySignature: null, bufferedReasoning: null, @@ -10260,6 +10790,7 @@ export function createAgentChatService(args: { if (!runtime.threadResumed) { const threadIdToResume = managed.session.threadId || readPersistedState(sessionId)?.threadId; const { codexPolicy, mcpServers } = resolveCodexThreadParams(managed); + const mcpConfig = buildCodexAppServerMcpConfigOverrides(mcpServers); if (threadIdToResume) { try { @@ -10268,6 +10799,7 @@ export function createAgentChatService(args: { model: managed.session.model, ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), cwd: managed.laneWorktreePath, + ...(mcpConfig ? { config: mcpConfig } : {}), mcpServers, mcp_servers: mcpServers, ...codexPolicyArgs(codexPolicy), @@ -10673,12 +11205,14 @@ export function createAgentChatService(args: { const threadId = persisted?.threadId ?? managed.session.threadId; if (threadId) { const { codexPolicy, mcpServers } = resolveCodexThreadParams(managed); + const mcpConfig = buildCodexAppServerMcpConfigOverrides(mcpServers); try { await runtime.request("thread/resume", { threadId, model: managed.session.model, ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), cwd: managed.laneWorktreePath, + ...(mcpConfig ? { config: mcpConfig } : {}), mcpServers, mcp_servers: mcpServers, ...codexPolicyArgs(codexPolicy), @@ -10753,8 +11287,19 @@ export function createAgentChatService(args: { ensureClaudeSessionRuntime(managed); // Re-sync permission mode from persisted/config settings const fallbackPermMode = resolveClaudeTurnPermissionMode(managed); - if (managed.runtime?.kind === "claude" && managed.runtime.v2Session && typeof managed.runtime.v2Session.setPermissionMode === "function") { - await managed.runtime.v2Session.setPermissionMode(fallbackPermMode); + if (managed.runtime?.kind === "claude" && managed.runtime.v2Session) { + const control = getClaudeV2SessionControl(managed.runtime.v2Session); + if (typeof control.setPermissionMode === "function") { + try { + await control.setPermissionMode(fallbackPermMode); + } catch { + // Session was created without --dangerously-skip-permissions. + // Invalidate so it is recreated with the correct mode. + try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } + managed.runtime.v2Session = null; + managed.runtime.v2WarmupDone = null; + } + } } sessionService.setResumeCommand(sessionId, `chat:claude:${sessionId}`); } @@ -10763,8 +11308,17 @@ export function createAgentChatService(args: { ensureClaudeSessionRuntime(managed); // Re-sync permission mode from persisted/config settings const claudePermMode = resolveClaudeTurnPermissionMode(managed); - if (managed.runtime?.kind === "claude" && managed.runtime.v2Session && typeof managed.runtime.v2Session.setPermissionMode === "function") { - await managed.runtime.v2Session.setPermissionMode(claudePermMode); + if (managed.runtime?.kind === "claude" && managed.runtime.v2Session) { + const control = getClaudeV2SessionControl(managed.runtime.v2Session); + if (typeof control.setPermissionMode === "function") { + try { + await control.setPermissionMode(claudePermMode); + } catch { + try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } + managed.runtime.v2Session = null; + managed.runtime.v2WarmupDone = null; + } + } } sessionService.setResumeCommand(sessionId, `chat:claude:${sessionId}`); } @@ -11335,6 +11889,7 @@ export function createAgentChatService(args: { managed.session.capabilityMode = inferCapabilityMode(nextProvider); if (previousProvider !== nextProvider || previousProvider === "codex") { delete managed.session.threadId; + managed.runtimeInvalidated = true; clearLaneDirectiveKey(managed); } sessionService.updateMeta({ @@ -11468,8 +12023,25 @@ export function createAgentChatService(args: { } if (managed.runtime?.kind === "claude" && managed.runtime.v2Session && !managed.runtime.busy) { const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); - if (typeof managed.runtime.v2Session.setPermissionMode === "function") { - await managed.runtime.v2Session.setPermissionMode(turnPermissionMode); + const control = getClaudeV2SessionControl(managed.runtime.v2Session); + if (typeof control.setPermissionMode === "function") { + try { + await control.setPermissionMode(turnPermissionMode); + } catch (permErr) { + // If the SDK rejects the mode change (e.g. escalating to + // bypassPermissions on a session not started with + // --dangerously-skip-permissions), invalidate the V2 session + // so it is recreated with the correct mode on the next turn. + logger.warn("agent_chat.v2_set_permission_mode_failed", { + sessionId: managed.session.id, + turnPermissionMode, + error: String(permErr), + }); + cancelClaudeWarmup(managed, managed.runtime, "session_reset"); + try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } + managed.runtime.v2Session = null; + managed.runtime.v2WarmupDone = null; + } } } if (managed.runtime?.kind === "cursor" && !managed.runtime.busy) { @@ -11788,6 +12360,7 @@ export function createAgentChatService(args: { warmupModel, listSubagents, getSessionCapabilities, + previewSessionToolNames, /** Clean up temp attachment files older than 7 days. Call on app startup. */ cleanupStaleAttachments() { try { diff --git a/apps/desktop/src/main/services/chat/chatTextBatching.test.ts b/apps/desktop/src/main/services/chat/chatTextBatching.test.ts index 802694b23..a63631cf4 100644 --- a/apps/desktop/src/main/services/chat/chatTextBatching.test.ts +++ b/apps/desktop/src/main/services/chat/chatTextBatching.test.ts @@ -102,7 +102,7 @@ describe("chatTextBatching", () => { })).toBe(false); }); - it("does not collapse anonymous text chunks that lack identity", () => { + it("coalesces anonymous text chunks that lack identity", () => { const buffered = appendBufferedAssistantText(null, { type: "text", text: "Hello", @@ -111,7 +111,7 @@ describe("chatTextBatching", () => { expect(canAppendBufferedAssistantText(buffered, { type: "text", text: " world", - })).toBe(false); + })).toBe(true); }); it("allows batching with only turnId (no itemId) on both sides", () => { diff --git a/apps/desktop/src/main/services/chat/chatTextBatching.ts b/apps/desktop/src/main/services/chat/chatTextBatching.ts index 814243ca3..9b116f528 100644 --- a/apps/desktop/src/main/services/chat/chatTextBatching.ts +++ b/apps/desktop/src/main/services/chat/chatTextBatching.ts @@ -27,8 +27,9 @@ export function canAppendBufferedAssistantText( } return false; } - // Don't collapse anonymous chunks that lack any identity - if (!buffered.turnId && !buffered.itemId && !event.turnId && !event.itemId) return false; + // Coalesce anonymous chunks that lack any identity — these are consecutive + // assistant text deltas from the same stream that simply have no IDs attached. + if (!buffered.turnId && !buffered.itemId && !event.turnId && !event.itemId) return true; return (buffered.turnId ?? null) === (event.turnId ?? null) && (buffered.itemId ?? null) === (event.itemId ?? null); } diff --git a/apps/desktop/src/main/services/chat/cursorAcpEventMapper.ts b/apps/desktop/src/main/services/chat/cursorAcpEventMapper.ts index e2332f4ad..c7ff3972c 100644 --- a/apps/desktop/src/main/services/chat/cursorAcpEventMapper.ts +++ b/apps/desktop/src/main/services/chat/cursorAcpEventMapper.ts @@ -79,7 +79,8 @@ function buildToolArgs(args: { kind?: string | null; locations?: ReadonlyArray<{ path?: string | null } | null> | null; }): Record { - const base = readObject(args.rawInput) ? { ...readObject(args.rawInput)! } : {}; + const parsed = readObject(args.rawInput); + const base = parsed ? { ...parsed } : {}; const title = typeof args.title === "string" ? args.title.trim() : ""; const kind = typeof args.kind === "string" ? args.kind.trim() : ""; if (title.length && typeof base.title !== "string") { @@ -372,8 +373,14 @@ export function mapStopReasonToTerminalEvents(args: { }); } - const doneStatus = - stopReason === "cancelled" ? "interrupted" : stopReason === "refusal" ? "failed" : "completed"; + let doneStatus: "interrupted" | "failed" | "completed"; + if (stopReason === "cancelled") { + doneStatus = "interrupted"; + } else if (stopReason === "refusal") { + doneStatus = "failed"; + } else { + doneStatus = "completed"; + } out.push({ type: "done", diff --git a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts index e98f19d74..7bfb4347d 100644 --- a/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts @@ -31,7 +31,7 @@ export function parseCursorCliModelsStdout(stdout: string): CursorCliModelRow[] const table = line.match(/^([a-z0-9][\w.-]*)\s+-\s+(.+)$/i); if (table) { const id = table[1].trim(); - let label = table[2].replace(/\s*\(current\)\s*$/i, "").trim(); + const label = table[2].replace(/\s*\(current\)\s*$/i, "").trim(); if (!seen.has(id)) { seen.add(id); out.push({ id, displayName: label }); @@ -84,9 +84,12 @@ export async function listCursorModelsFromCli(agentPath: string): Promise; - const id = typeof r.id === "string" ? r.id : typeof r.model === "string" ? r.model : ""; - const displayName = typeof r.name === "string" ? r.name : typeof r.displayName === "string" ? r.displayName : undefined; - if (id.trim()) models.push({ id: id.trim(), displayName }); + const trimmedId = typeof r.id === "string" ? r.id.trim() : ""; + const trimmedModel = typeof r.model === "string" ? r.model.trim() : ""; + const id = trimmedId || trimmedModel; + const displayName = (typeof r.name === "string" ? r.name : undefined) + ?? (typeof r.displayName === "string" ? r.displayName : undefined); + if (id) models.push({ id, displayName }); } } if (models.length) { diff --git a/apps/desktop/src/main/services/computerUse/proofObserver.test.ts b/apps/desktop/src/main/services/computerUse/proofObserver.test.ts index 129b3e9ed..1e743eba4 100644 --- a/apps/desktop/src/main/services/computerUse/proofObserver.test.ts +++ b/apps/desktop/src/main/services/computerUse/proofObserver.test.ts @@ -95,4 +95,25 @@ describe("proofObserver", () => { }), ]); }); + + it("ignores image URLs embedded inside PR comment bodies", () => { + const { observer, requests } = createHarness(); + + observer.observe({ + type: "tool_result", + tool: "mcp__ade__pr_get_review_comments", + result: { + comments: [ + { + author: "cursor[bot]", + body: "Cursor logo: https://cursor.com/assets/logo.png", + }, + ], + }, + itemId: "item-4", + status: "completed", + }, "chat-1"); + + expect(requests).toHaveLength(0); + }); }); diff --git a/apps/desktop/src/main/services/computerUse/proofObserver.ts b/apps/desktop/src/main/services/computerUse/proofObserver.ts index 782c126c5..14e3c992a 100644 --- a/apps/desktop/src/main/services/computerUse/proofObserver.ts +++ b/apps/desktop/src/main/services/computerUse/proofObserver.ts @@ -43,6 +43,8 @@ const TRACE_EXTENSIONS = /\.(zip|trace)$/i; const LOG_EXTENSIONS = /\.(log|txt|ndjson|jsonl)$/i; const ARTIFACT_FIELD_NAMES = /screenshot|image|proof|recording|video|capture|snapshot|trace|console|log/i; +const EMBEDDED_ARTIFACT_CONTEXT_FIELDS = /stdout|stderr|output|result|response|trace|console|log/i; +const TEXTUAL_CONTENT_FIELD_NAMES = /body|comment|content|text|markdown|description|summary|headline|title|html|note/i; const TRACE_FIELD_NAMES = /trace/i; const LOG_FIELD_NAMES = /console|log/i; const BASE64_IMAGE_URI = /^data:image\/[a-z+]+;base64,/i; @@ -262,6 +264,11 @@ function scanResultForArtifacts( if (depth > 10) return; if (typeof value === "string") { + const fieldLooksTextual = fieldName != null + && TEXTUAL_CONTENT_FIELD_NAMES.test(fieldName) + && !ARTIFACT_FIELD_NAMES.test(fieldName); + if (fieldLooksTextual) return; + // Check for base64 data URIs const exactCandidate = looksLikeDirectArtifactLocator(value) ? buildCandidateFromString(value.trim(), fieldName) @@ -271,7 +278,12 @@ function scanResultForArtifacts( return; } - const embeddedMatches = value.match(EMBEDDED_ARTIFACT_PATTERN) ?? []; + const allowEmbeddedMatches = fieldName == null + || ARTIFACT_FIELD_NAMES.test(fieldName) + || EMBEDDED_ARTIFACT_CONTEXT_FIELDS.test(fieldName); + const embeddedMatches = allowEmbeddedMatches + ? (value.match(EMBEDDED_ARTIFACT_PATTERN) ?? []) + : []; if (embeddedMatches.length > 0) { for (const match of embeddedMatches) { const embeddedCandidate = buildCandidateFromString(match, fieldName); diff --git a/apps/desktop/src/main/services/conflicts/conflictService.test.ts b/apps/desktop/src/main/services/conflicts/conflictService.test.ts index 8ea3aea4d..e0d745e31 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.test.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.test.ts @@ -1129,6 +1129,178 @@ describe("conflictService conflict context integrity", () => { } }); + it("prefers the local parent branch over a stale remote parent branch", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-local-parent-")); + const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-local-parent-remote-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const projectId = randomUUID(); + const now = "2026-03-24T12:00:00.000Z"; + + try { + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "demo", "main", now, now] + ); + + git(remoteRoot, ["init", "--bare"]); + + fs.writeFileSync(path.join(repoRoot, "file.txt"), "base\n", "utf8"); + git(repoRoot, ["init", "-b", "main"]); + git(repoRoot, ["config", "user.email", "ade@test.local"]); + git(repoRoot, ["config", "user.name", "ADE Test"]); + git(repoRoot, ["remote", "add", "origin", remoteRoot]); + git(repoRoot, ["add", "."]); + git(repoRoot, ["commit", "-m", "base"]); + git(repoRoot, ["push", "-u", "origin", "main"]); + + git(repoRoot, ["checkout", "-b", "feature/worktree-parent"]); + fs.writeFileSync(path.join(repoRoot, "parent.txt"), "parent work\n", "utf8"); + git(repoRoot, ["add", "parent.txt"]); + git(repoRoot, ["commit", "-m", "parent work"]); + git(repoRoot, ["push", "-u", "origin", "feature/worktree-parent"]); + + git(repoRoot, ["checkout", "-b", "feature/grandchild"]); + fs.writeFileSync(path.join(repoRoot, "child.txt"), "grandchild\n", "utf8"); + git(repoRoot, ["add", "child.txt"]); + git(repoRoot, ["commit", "-m", "grandchild work"]); + + git(repoRoot, ["checkout", "feature/worktree-parent"]); + fs.writeFileSync(path.join(repoRoot, "parent2.txt"), "parent advance\n", "utf8"); + git(repoRoot, ["add", "parent2.txt"]); + git(repoRoot, ["commit", "-m", "parent advance"]); + git(repoRoot, ["checkout", "feature/grandchild"]); + + const parentLane = createLaneSummary(repoRoot, { + id: "lane-wt-parent", + name: "Worktree Parent", + branchRef: "feature/worktree-parent", + baseRef: "main", + parentLaneId: null, + }); + const childLane = createLaneSummary(repoRoot, { + id: "lane-grandchild", + name: "Grandchild", + branchRef: "feature/grandchild", + baseRef: "main", + parentLaneId: "lane-wt-parent", + }); + + const service = createConflictService({ + db, + logger: createLogger(), + projectId, + projectRoot: repoRoot, + laneService: { + list: async () => [parentLane, childLane], + getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, baseRef: "main", branchRef: "feature/grandchild" }), + } as any, + projectConfigService: { + get: () => ({ effective: { providerMode: "guest" }, local: {} }), + } as any, + }); + + const needs = await service.scanRebaseNeeds(); + expect(needs).toHaveLength(1); + expect(needs[0]).toMatchObject({ + laneId: "lane-grandchild", + baseBranch: "feature/worktree-parent", + }); + expect(needs[0]!.behindBy).toBeGreaterThan(0); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + fs.rmSync(remoteRoot, { recursive: true, force: true }); + } + }); + + it("keeps rebase targets hierarchy-safe by surfacing only the direct parent need for descendants", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-direct-parent-only-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const projectId = randomUUID(); + const now = "2026-03-24T12:00:00.000Z"; + + try { + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "demo", "main", now, now] + ); + + fs.writeFileSync(path.join(repoRoot, "file.txt"), "base\n", "utf8"); + git(repoRoot, ["init", "-b", "main"]); + git(repoRoot, ["config", "user.email", "ade@test.local"]); + git(repoRoot, ["config", "user.name", "ADE Test"]); + git(repoRoot, ["add", "."]); + git(repoRoot, ["commit", "-m", "base"]); + + git(repoRoot, ["checkout", "-b", "feature/parent"]); + fs.writeFileSync(path.join(repoRoot, "parent.txt"), "parent work\n", "utf8"); + git(repoRoot, ["add", "parent.txt"]); + git(repoRoot, ["commit", "-m", "parent work"]); + + git(repoRoot, ["checkout", "-b", "feature/grandchild"]); + fs.writeFileSync(path.join(repoRoot, "grandchild.txt"), "grandchild work\n", "utf8"); + git(repoRoot, ["add", "grandchild.txt"]); + git(repoRoot, ["commit", "-m", "grandchild work"]); + + git(repoRoot, ["checkout", "main"]); + fs.writeFileSync(path.join(repoRoot, "main-advance.txt"), "main advance\n", "utf8"); + git(repoRoot, ["add", "main-advance.txt"]); + git(repoRoot, ["commit", "-m", "main advance"]); + git(repoRoot, ["checkout", "feature/grandchild"]); + + const primaryLane = { + ...createLaneSummary(repoRoot, { + id: "lane-root", + name: "Main", + branchRef: "main", + baseRef: "main", + parentLaneId: null, + }), + laneType: "primary" as const, + }; + const parentLane = createLaneSummary(repoRoot, { + id: "lane-parent", + name: "Parent", + branchRef: "feature/parent", + baseRef: "main", + parentLaneId: "lane-root", + }); + const grandchildLane = createLaneSummary(repoRoot, { + id: "lane-grandchild", + name: "Grandchild", + branchRef: "feature/grandchild", + baseRef: "main", + parentLaneId: "lane-parent", + }); + + const service = createConflictService({ + db, + logger: createLogger(), + projectId, + projectRoot: repoRoot, + laneService: { + list: async () => [primaryLane, parentLane, grandchildLane], + getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, baseRef: "main", branchRef: "feature/grandchild" }), + } as any, + projectConfigService: { + get: () => ({ effective: { providerMode: "guest" }, local: {} }), + } as any, + }); + + const needs = await service.scanRebaseNeeds(); + expect(needs).toEqual([ + expect.objectContaining({ + laneId: "lane-parent", + kind: "lane_base", + baseBranch: "main", + }), + ]); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it("does not treat a primary parent as the live base when the lane is anchored elsewhere", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-primary-fallback-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index 911571b2f..53fa1ed0a 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -290,10 +290,9 @@ function resolveLaneRebaseTarget(args: { const parent = args.lane.parentLaneId ? args.lanesById.get(args.lane.parentLaneId) ?? null : null; const parentBranchRef = branchNameFromLaneRef(parent?.branchRef); if (parentBranchRef && shouldLaneTrackParent({ lane: args.lane, parent })) { - const comparisonRef = `origin/${parentBranchRef}`; return { - comparisonRef, - fallbackRef: parentBranchRef, + comparisonRef: parentBranchRef, + fallbackRef: `origin/${parentBranchRef}`, displayBaseBranch: parentBranchRef, }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index b87cc4950..c2ee480b6 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -129,6 +129,8 @@ import type { PrIssueResolutionStartResult, IssueInventoryItem, IssueInventorySnapshot, + ConvergenceRuntimeState, + PrConvergenceStatePatch, ConvergenceStatus, PipelineSettings, RebaseResolutionStartArgs, @@ -5018,15 +5020,39 @@ export function registerIpc({ ipcMain.handle(IPC.prsIssueResolutionStart, async (_event, arg: PrIssueResolutionStartArgs): Promise => { const ctx = getCtx(); - return await launchPrIssueResolutionChat( + const result = await launchPrIssueResolutionChat( { prService: ctx.prService, laneService: ctx.laneService, agentChatService: ctx.agentChatService, sessionService: ctx.sessionService, + issueInventoryService: ctx.issueInventoryService, }, arg, ); + try { + const status = ctx.issueInventoryService.getConvergenceStatus(arg.prId); + ctx.issueInventoryService.saveConvergenceRuntime(arg.prId, { + currentRound: status.currentRound, + status: "running", + pollerStatus: "idle", + activeSessionId: result.sessionId, + activeLaneId: result.laneId, + activeHref: result.href, + lastStartedAt: nowIso(), + errorMessage: null, + pauseReason: null, + }); + } catch (error) { + ctx.logger.warn("ipc.prs_issue_resolution_convergence_persist_failed", { + prId: arg.prId, + sessionId: result.sessionId, + laneId: result.laneId, + href: result.href, + error: getErrorMessage(error), + }); + } + return result; }); ipcMain.handle(IPC.prsIssueResolutionPreviewPrompt, async ( @@ -5040,6 +5066,7 @@ export function registerIpc({ laneService: ctx.laneService, agentChatService: ctx.agentChatService, sessionService: ctx.sessionService, + issueInventoryService: ctx.issueInventoryService, }, arg, ); @@ -5100,6 +5127,78 @@ export function registerIpc({ ipcMain.handle(IPC.prsIssueInventoryReset, (_e, args: { prId: string }): void => getCtx().issueInventoryService.resetInventory(args.prId)); + ipcMain.handle(IPC.prsConvergenceStateGet, (_e, args: { prId: string }): ConvergenceRuntimeState => + getCtx().issueInventoryService.getConvergenceRuntime(args.prId)); + ipcMain.handle(IPC.prsConvergenceStateSave, (_e, args: { prId: string; state: PrConvergenceStatePatch }): ConvergenceRuntimeState => { + // Whitelist: only allow renderer to update operational fields. + // Identity fields and immutable timestamps are stripped. + const MUTABLE_FIELDS: ReadonlySet = new Set([ + "autoConvergeEnabled", + "status", + "pollerStatus", + "currentRound", + "activeSessionId", + "activeLaneId", + "activeHref", + "pauseReason", + "errorMessage", + "lastStartedAt", + "lastPolledAt", + "lastPausedAt", + "lastStoppedAt", + ]); + // Validate that args.state is a plain non-null object before iterating. + if (args.state == null || typeof args.state !== "object" || Array.isArray(args.state)) { + return getCtx().issueInventoryService.getConvergenceRuntime(args.prId); + } + + const VALID_STATUS: ReadonlySet = new Set([ + "idle", "launching", "running", "polling", "paused", "converged", "merged", "failed", "cancelled", "stopped", + ]); + const VALID_POLLER_STATUS: ReadonlySet = new Set([ + "idle", "scheduled", "polling", "waiting_for_checks", "waiting_for_comments", "paused", "stopped", + ]); + + const isStringOrNull = (v: unknown): boolean => v === null || typeof v === "string"; + + const sanitized: PrConvergenceStatePatch = {}; + for (const key of Object.keys(args.state) as (keyof ConvergenceRuntimeState)[]) { + if (!MUTABLE_FIELDS.has(key)) continue; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + const val = (args.state as any)[key]; + switch (key) { + case "autoConvergeEnabled": + if (typeof val === "boolean") sanitized.autoConvergeEnabled = val; + break; + case "status": + if (typeof val === "string" && VALID_STATUS.has(val)) sanitized.status = val as ConvergenceRuntimeState["status"]; + break; + case "pollerStatus": + if (typeof val === "string" && VALID_POLLER_STATUS.has(val)) sanitized.pollerStatus = val as ConvergenceRuntimeState["pollerStatus"]; + break; + case "currentRound": + if (typeof val === "number" && Number.isFinite(val) && val >= 0) sanitized.currentRound = val; + break; + case "activeSessionId": + case "activeLaneId": + case "activeHref": + case "pauseReason": + case "errorMessage": + case "lastStartedAt": + case "lastPolledAt": + case "lastPausedAt": + case "lastStoppedAt": + if (isStringOrNull(val)) (sanitized as any)[key] = val; + break; + default: + break; + } + } + return getCtx().issueInventoryService.saveConvergenceRuntime(args.prId, sanitized); + }); + ipcMain.handle(IPC.prsConvergenceStateDelete, (_e, args: { prId: string }): void => + getCtx().issueInventoryService.resetConvergenceRuntime(args.prId)); + ipcMain.handle(IPC.prsPipelineSettingsGet, (_e, args: { prId: string }): PipelineSettings => getCtx().issueInventoryService.getPipelineSettings(args.prId)); ipcMain.handle(IPC.prsPipelineSettingsSave, (_e, args: { prId: string; settings: Partial }): void => diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 94561a143..3d89eee38 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -2487,6 +2487,11 @@ export function createLaneService({ db.run("update lanes set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from pr_group_members where lane_id = ?", [laneId]); + // Explicitly delete child rows that rely on FK cascade — CRR conversion can + // strip checked foreign keys, leaving orphaned rows if we only rely on CASCADE. + db.run("delete from pr_convergence_state where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from pr_pipeline_settings where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from pr_issue_inventory where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from pull_requests where lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from session_deltas where lane_id = ?", [laneId]); db.run("delete from terminal_sessions where lane_id = ?", [laneId]); diff --git a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts index ef344b911..57841c4b0 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.test.ts @@ -16,6 +16,7 @@ import { describe("buildCodexMcpConfigFlags", () => { it("shell-escapes TOML override values so zsh does not parse brackets or spaces", () => { const flags = buildCodexMcpConfigFlags({ + projectRoot: "/Users/admin/Projects/ADE", workspaceRoot: "/Users/admin/Projects/ADE", runtimeRoot: "/tmp/ade-runtime", preferBundledProxy: false, diff --git a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts index 44f4383c2..cf6b072ef 100644 --- a/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/unifiedOrchestratorAdapter.ts @@ -56,7 +56,7 @@ function resolveWorkerOwnerId(metadata: Record | null | undefin } export function resolveAdeMcpServerLaunch(args: { - projectRoot?: string; + projectRoot: string; workspaceRoot: string; runtimeRoot: string; missionId?: string; @@ -207,7 +207,7 @@ export function resolveUnifiedRuntimeRoot(): string { * `-c key=value` flag overrides individual dotted TOML paths. */ export function buildCodexMcpConfigFlags(args: { - projectRoot?: string; + projectRoot: string; workspaceRoot: string; runtimeRoot: string; missionId?: string; diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.test.ts b/apps/desktop/src/main/services/prs/issueInventoryService.test.ts index 226f59aa1..5454541b9 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.test.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.test.ts @@ -102,12 +102,39 @@ function makeFakeRow(overrides: Record = {}) { url: "https://example.com/thread/1", dismiss_reason: null, agent_session_id: null, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + thread_latest_comment_author: "reviewer", + thread_latest_comment_at: "2026-03-23T12:00:00.000Z", + thread_latest_comment_source: "unknown", created_at: "2026-03-23T12:00:00.000Z", updated_at: "2026-03-23T12:00:00.000Z", ...overrides, }; } +function makeRuntimeRow(overrides: Record = {}) { + return { + pr_id: PR_ID, + auto_converge_enabled: 1, + status: "running", + poller_status: "waiting_for_comments", + current_round: 2, + active_session_id: "session-1", + active_lane_id: "lane-1", + active_href: "/work?laneId=lane-1&sessionId=session-1", + pause_reason: null, + error_message: null, + last_started_at: "2026-03-23T12:00:00.000Z", + last_polled_at: "2026-03-23T12:01:00.000Z", + last_paused_at: null, + last_stopped_at: null, + created_at: "2026-03-23T11:59:00.000Z", + updated_at: "2026-03-23T12:01:00.000Z", + ...overrides, + }; +} + // --------------------------------------------------------------------------- // Tests — syncFromPrData // --------------------------------------------------------------------------- @@ -146,7 +173,7 @@ describe("issueInventoryService", () => { expect(insertArgs[2]).toBe("unknown"); // source (checks have unknown source) expect(insertArgs[3]).toBe("check_failure"); // type expect(insertArgs[4]).toBe('check:ci / lint'); // externalId - expect(insertArgs[7]).toBe("major"); // severity + expect(insertArgs[9]).toBe("major"); // severity // Result snapshot expect(result.prId).toBe(PR_ID); @@ -195,6 +222,53 @@ describe("issueInventoryService", () => { expect(args[3]).toBe("review_thread"); // type }); + it("tracks the latest reply in a review thread", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-latest", + comments: [ + { + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "**Minor** Initial concern.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + { + id: "comment-2", + author: "coderabbitai[bot]", + authorAvatarUrl: null, + body: "**Major** This still needs a fix.", + url: "https://example.com/comment/2", + createdAt: "2026-03-23T12:02:00.000Z", + updatedAt: "2026-03-23T12:02:00.000Z", + }, + ], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + expect(insertCalls.length).toBe(1); + const args = insertCalls[0][1] as unknown[]; + expect(args[11]).toContain("This still needs a fix."); + expect(args[12]).toBe("coderabbitai[bot]"); + expect(args[16]).toBe(2); + expect(args[17]).toBe("comment-2"); + expect(args[20]).toBe("coderabbit"); + }); + it("skips resolved review threads", () => { const db = makeMockDb(); db.get.mockReturnValue(null); @@ -233,6 +307,112 @@ describe("issueInventoryService", () => { expect(insertCalls.length).toBe(0); }); + it("marks previously tracked resolved threads as fixed", () => { + const db = makeMockDb(); + db.get.mockReturnValue(makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-resolved", + state: "sent_to_agent", + round: 2, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + })); + db.all.mockReturnValue([makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-resolved", + state: "fixed", + round: 2, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + })]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-resolved", + isResolved: true, + comments: [{ + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Resolved in code.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }], + })], + [], + ); + + const updateCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("update pr_issue_inventory"), + ); + expect(updateCalls.length).toBe(1); + const params = updateCalls[0][1] as unknown[]; + expect(params[8]).toBe("fixed"); + }); + + it("reopens a thread as new when a new reply appears", () => { + const db = makeMockDb(); + db.get.mockReturnValue(makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-reopened", + state: "sent_to_agent", + round: 2, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + })); + db.all.mockReturnValue([makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-reopened", + state: "new", + round: 2, + thread_comment_count: 2, + thread_latest_comment_id: "comment-2", + })]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-reopened", + comments: [ + { + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Initial thread comment.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + { + id: "comment-2", + author: "coderabbitai[bot]", + authorAvatarUrl: null, + body: "This still needs attention.", + url: null, + createdAt: "2026-03-23T12:03:00.000Z", + updatedAt: "2026-03-23T12:03:00.000Z", + }, + ], + })], + [], + ); + + const updateCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("update pr_issue_inventory"), + ); + expect(updateCalls.length).toBe(1); + const params = updateCalls[0][1] as unknown[]; + expect(params[8]).toBe("new"); + expect(params[11]).toBeNull(); + expect(params[12]).toBe(2); + }); + it("detects coderabbit bot as source from review thread author", () => { const db = makeMockDb(); db.get.mockReturnValue(null); @@ -324,7 +504,7 @@ describe("issueInventoryService", () => { expect(args[2]).toBe("copilot"); // source }); - it("maps unknown authors as human source", () => { + it("maps unmatched human authors as human source", () => { const db = makeMockDb(); db.get.mockReturnValue(null); db.all.mockReturnValue([]); @@ -354,6 +534,36 @@ describe("issueInventoryService", () => { expect(args[2]).toBe("human"); // source }); + it("maps unrecognized bot authors as unknown source", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "thread-comment-1", + author: "greptile-review[bot]", + authorAvatarUrl: null, + body: "Potential issue in this block.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[2]).toBe("unknown"); // source + }); + it("extracts severity from bold keywords (Critical/Major/Minor)", () => { const db = makeMockDb(); db.get.mockReturnValue(null); @@ -381,7 +591,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("critical"); // severity + expect(args[9]).toBe("critical"); // severity }); it("extracts severity from P1/P2/P3 labels", () => { @@ -414,7 +624,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("major"); // severity from P2 + expect(args[9]).toBe("major"); // severity from P2 }); it("extracts severity from emoji indicators", () => { @@ -444,7 +654,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("critical"); + expect(args[9]).toBe("critical"); }); it("extracts severity from bracket patterns like [bug]", () => { @@ -474,7 +684,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("major"); // [warning] -> major + expect(args[9]).toBe("major"); // [warning] -> major }); it("extracts headline from bold title in body", () => { @@ -505,7 +715,7 @@ describe("issueInventoryService", () => { ); const args = insertCalls[0][1] as unknown[]; // The headline should be the extracted bold title, cleaned of emoji noise - const headline = args[8] as string; + const headline = args[10] as string; expect(headline).toContain("Derive"); expect(headline).toContain("assistantLabel"); }); @@ -537,7 +747,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - const headline = args[8] as string; + const headline = args[10] as string; expect(headline).toContain("Consider using a Map"); }); @@ -987,6 +1197,113 @@ describe("issueInventoryService", () => { }); }); + // --------------------------------------------------------------------------- + // Tests — convergence runtime state + // --------------------------------------------------------------------------- + + describe("convergence runtime state", () => { + it("returns convergence runtime state from the database", () => { + const db = makeMockDb(); + db.get.mockImplementation((sql: string) => { + if (sql.includes("from pr_convergence_state")) { + return makeRuntimeRow({ + auto_converge_enabled: 0, + status: "polling", + poller_status: "waiting_for_checks", + }); + } + return null; + }); + + const service = createIssueInventoryService({ db }); + const runtime = service.getConvergenceRuntime(PR_ID); + + expect(runtime).toEqual(expect.objectContaining({ + prId: PR_ID, + autoConvergeEnabled: false, + status: "polling", + pollerStatus: "waiting_for_checks", + })); + }); + + it("upserts convergence runtime state", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + const runtime = service.saveConvergenceRuntime(PR_ID, { + autoConvergeEnabled: true, + status: "running", + pollerStatus: "scheduled", + currentRound: 3, + activeSessionId: "session-9", + activeLaneId: "lane-9", + activeHref: "/work?laneId=lane-9&sessionId=session-9", + pauseReason: null, + errorMessage: null, + }); + + expect(runtime.prId).toBe(PR_ID); + expect(runtime.autoConvergeEnabled).toBe(true); + expect(runtime.status).toBe("running"); + expect(db.run).toHaveBeenCalledTimes(1); + const sql = db.run.mock.calls[0][0] as string; + expect(sql).toContain("insert into pr_convergence_state"); + const params = db.run.mock.calls[0][1] as unknown[]; + expect(params[0]).toBe(PR_ID); + expect(params[1]).toBe(1); + expect(params[2]).toBe("running"); + expect(params[3]).toBe("scheduled"); + expect(params[4]).toBe(3); + expect(params[5]).toBe("session-9"); + }); + + it("reconciles active convergence sessions when a tracked chat exits", () => { + const db = makeMockDb(); + db.all.mockImplementation((sql: string, params?: unknown[]) => { + if (sql.includes("from pr_convergence_state where active_session_id = ?")) { + expect(params).toEqual(["session-1"]); + return [makeRuntimeRow()]; + } + return []; + }); + db.get.mockImplementation((sql: string) => { + if (sql.includes("from pr_convergence_state")) { + return makeRuntimeRow(); + } + return null; + }); + + const service = createIssueInventoryService({ db }); + const reconciled = service.reconcileConvergenceSessionExit("session-1", { exitCode: 0 }); + + expect(reconciled).toHaveLength(1); + expect(reconciled[0]).toEqual(expect.objectContaining({ + prId: PR_ID, + status: "paused", + pollerStatus: "paused", + activeSessionId: null, + pauseReason: "Agent session ended. Refresh the PR to reconcile checks and continue.", + })); + expect(db.run).toHaveBeenCalledWith( + expect.stringContaining("insert into pr_convergence_state"), + expect.arrayContaining([PR_ID, 1, "paused", "paused", 2, null]), + ); + }); + + it("deletes convergence runtime state on reset", () => { + const db = makeMockDb(); + const service = createIssueInventoryService({ db }); + + service.resetConvergenceRuntime(PR_ID); + + expect(db.run).toHaveBeenCalledWith( + "delete from pr_convergence_state where pr_id = ?", + [PR_ID], + ); + }); + }); + // --------------------------------------------------------------------------- // Tests — pipeline settings // --------------------------------------------------------------------------- @@ -1115,7 +1432,7 @@ describe("issueInventoryService", () => { const insertCalls = db.run.mock.calls.filter( (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); - const headline = insertCalls[0][1][8] as string; + const headline = insertCalls[0][1][10] as string; // Should have stripped "⚠️" emoji but kept the useful text expect(headline).not.toContain("⚠️"); expect(headline).toContain("Fix the race condition"); @@ -1148,7 +1465,7 @@ describe("issueInventoryService", () => { const insertCalls = db.run.mock.calls.filter( (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); - const headline = insertCalls[0][1][8] as string; + const headline = insertCalls[0][1][10] as string; expect(headline).toContain("Review thread at src/utils.ts"); }); @@ -1179,7 +1496,7 @@ describe("issueInventoryService", () => { const insertCalls = db.run.mock.calls.filter( (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); - const headline = insertCalls[0][1][8] as string; + const headline = insertCalls[0][1][10] as string; expect(headline.length).toBeLessThanOrEqual(120); expect(headline).toContain("..."); }); @@ -1212,7 +1529,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); expect(insertCalls.length).toBe(1); - const headline = insertCalls[0][1][8] as string; + const headline = insertCalls[0][1][10] as string; expect(headline).toBe("Review thread at src/main.ts"); }); @@ -1238,7 +1555,7 @@ describe("issueInventoryService", () => { expect(insertCalls.length).toBe(1); // author null, headline should use fallback const args = insertCalls[0][1] as unknown[]; - expect(args[10]).toBeNull(); // author + expect(args[12]).toBeNull(); // author }); it("detects ade-review bot source", () => { @@ -1328,7 +1645,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBeNull(); // severity + expect(args[9]).toBeNull(); // severity }); it("extracts P1 as critical severity", () => { @@ -1358,7 +1675,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("critical"); + expect(args[9]).toBe("critical"); }); it("extracts P3 as minor severity", () => { @@ -1388,7 +1705,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("minor"); + expect(args[9]).toBe("minor"); }); it("extracts [nit] bracket as minor severity", () => { @@ -1418,7 +1735,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("minor"); + expect(args[9]).toBe("minor"); }); it("extracts [error] bracket as critical severity", () => { @@ -1448,7 +1765,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("critical"); + expect(args[9]).toBe("critical"); }); it("extracts 🟠 emoji as major severity", () => { @@ -1478,7 +1795,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("major"); + expect(args[9]).toBe("major"); }); it("extracts 🟡 emoji as minor severity", () => { @@ -1508,7 +1825,227 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[7]).toBe("minor"); + expect(args[9]).toBe("minor"); + }); + }); + + // --------------------------------------------------------------------------- + // Tests — validateConvergenceRuntimeState (Issue 1) + // --------------------------------------------------------------------------- + + describe("convergence runtime validation", () => { + it("rejects an unknown status value", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + expect(() => + service.saveConvergenceRuntime(PR_ID, { status: "bogus" as any }), + ).toThrow(/Invalid convergence runtime status/); + }); + + it("rejects an unknown pollerStatus value", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + expect(() => + service.saveConvergenceRuntime(PR_ID, { pollerStatus: "made_up" as any }), + ).toThrow(/Invalid convergence poller status/); + }); + + it("rejects a negative currentRound", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + expect(() => + service.saveConvergenceRuntime(PR_ID, { currentRound: -1 }), + ).toThrow(/Invalid currentRound/); + }); + + it("rejects a non-integer currentRound", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + expect(() => + service.saveConvergenceRuntime(PR_ID, { currentRound: 2.5 }), + ).toThrow(/Invalid currentRound/); + }); + + it("rejects NaN currentRound", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + expect(() => + service.saveConvergenceRuntime(PR_ID, { currentRound: NaN }), + ).toThrow(/Invalid currentRound/); + }); + + it("accepts valid runtime state fields", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + expect(() => + service.saveConvergenceRuntime(PR_ID, { + status: "running", + pollerStatus: "polling", + currentRound: 3, + }), + ).not.toThrow(); + }); + }); + + // --------------------------------------------------------------------------- + // Tests — reopen on latest-comment edits (Issue 2) + // --------------------------------------------------------------------------- + + describe("thread reopen on comment edit", () => { + it("reopens a fixed thread when the latest comment is edited in place", () => { + const db = makeMockDb(); + db.get.mockReturnValue(makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-edited", + state: "fixed", + round: 1, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + thread_latest_comment_at: "2026-03-23T12:00:00.000Z", + })); + db.all.mockReturnValue([makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-edited", + state: "new", + round: 1, + })]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-edited", + comments: [{ + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "**Critical** Actually this is worse than I thought.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + // Same comment ID, but updatedAt is newer than stored thread_latest_comment_at + updatedAt: "2026-03-23T14:00:00.000Z", + }], + })], + [], + ); + + const updateCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("update pr_issue_inventory"), + ); + expect(updateCalls.length).toBe(1); + const params = updateCalls[0][1] as unknown[]; + // state should be "new" (reopened) because the comment was edited + expect(params[8]).toBe("new"); + // agentSessionId should be cleared + expect(params[11]).toBeNull(); + }); + + it("does not reopen when the latest comment has not been edited", () => { + const db = makeMockDb(); + db.get.mockReturnValue(makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-unchanged", + state: "fixed", + round: 1, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + thread_latest_comment_at: "2026-03-23T12:00:00.000Z", + })); + db.all.mockReturnValue([makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-unchanged", + state: "fixed", + round: 1, + })]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-unchanged", + comments: [{ + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Fix the null check.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + // Same updatedAt as stored — no edit + updatedAt: "2026-03-23T12:00:00.000Z", + }], + })], + [], + ); + + const updateCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("update pr_issue_inventory"), + ); + expect(updateCalls.length).toBe(1); + const params = updateCalls[0][1] as unknown[]; + // state should remain "fixed" because nothing changed + expect(params[8]).toBe("fixed"); + }); + + it("reopens a dismissed thread when the latest comment is edited", () => { + const db = makeMockDb(); + db.get.mockReturnValue(makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-dismissed-edited", + state: "dismissed", + dismiss_reason: "Not relevant", + round: 1, + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + thread_latest_comment_at: "2026-03-23T12:00:00.000Z", + })); + db.all.mockReturnValue([makeFakeRow({ + id: "existing-thread-item", + external_id: "thread:thread-dismissed-edited", + state: "new", + round: 1, + })]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-dismissed-edited", + comments: [{ + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Updated: this is actually a real problem.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T15:00:00.000Z", + }], + })], + [], + ); + + const updateCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("update pr_issue_inventory"), + ); + expect(updateCalls.length).toBe(1); + const params = updateCalls[0][1] as unknown[]; + expect(params[8]).toBe("new"); + // dismiss_reason should be cleared + expect(params[10]).toBeNull(); }); }); }); diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.ts b/apps/desktop/src/main/services/prs/issueInventoryService.ts index e43287718..87c198012 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.ts @@ -3,6 +3,7 @@ import type { AdeDb } from "../state/kvDb"; import type { ConvergenceRoundStat, ConvergenceStatus, + ConvergenceRuntimeState, IssueInventoryItem, IssueInventorySnapshot, IssueInventoryState, @@ -12,7 +13,7 @@ import type { PrComment, PrReviewThread, } from "../../../shared/types"; -import { DEFAULT_PIPELINE_SETTINGS } from "../../../shared/types"; +import { DEFAULT_CONVERGENCE_RUNTIME_STATE, DEFAULT_PIPELINE_SETTINGS } from "../../../shared/types"; import { isNoisyIssueComment } from "./resolverUtils"; import { nowIso } from "../shared/utils"; @@ -29,12 +30,36 @@ const SOURCE_PATTERNS: Array<{ pattern: RegExp; source: IssueSource }> = [ { pattern: /^ade-review(\[bot\])?$/i, source: "ade" }, ]; -function detectSource(author: string | null | undefined): IssueSource { +const CONVERGENCE_RUNTIME_STATUS_VALUES = new Set([ + "idle", + "launching", + "running", + "polling", + "paused", + "converged", + "merged", + "failed", + "cancelled", + "stopped", +]); + +const CONVERGENCE_POLLER_STATUS_VALUES = new Set([ + "idle", + "scheduled", + "polling", + "waiting_for_checks", + "waiting_for_comments", + "paused", + "stopped", +]); + +export function detectSource(author: string | null | undefined): IssueSource { const name = (author ?? "").trim(); if (!name) return "unknown"; for (const { pattern, source } of SOURCE_PATTERNS) { if (pattern.test(name)) return source; } + if (/\[bot\]/i.test(name) || /\bbot\b/i.test(name)) return "unknown"; return "human"; } @@ -42,7 +67,7 @@ function detectSource(author: string | null | undefined): IssueSource { // Severity extraction — reuses the same pattern as prIssueResolver.ts // --------------------------------------------------------------------------- -function extractSeverity(value: string): "critical" | "major" | "minor" | null { +export function extractSeverity(value: string): "critical" | "major" | "minor" | null { // Match explicit severity words const wordMatch = value.match(/\b(Critical|Major|Minor)\b/i); if (wordMatch?.[1]) return wordMatch[1].toLowerCase() as "critical" | "major" | "minor"; @@ -120,6 +145,11 @@ type InventoryRow = { url: string | null; dismiss_reason: string | null; agent_session_id: string | null; + thread_comment_count: number | null; + thread_latest_comment_id: string | null; + thread_latest_comment_author: string | null; + thread_latest_comment_at: string | null; + thread_latest_comment_source: string | null; created_at: string; updated_at: string; }; @@ -142,6 +172,11 @@ function rowToItem(row: InventoryRow): IssueInventoryItem { url: row.url, dismissReason: row.dismiss_reason, agentSessionId: row.agent_session_id, + threadCommentCount: row.thread_comment_count, + threadLatestCommentId: row.thread_latest_comment_id, + threadLatestCommentAuthor: row.thread_latest_comment_author, + threadLatestCommentAt: row.thread_latest_comment_at, + threadLatestCommentSource: row.thread_latest_comment_source as IssueSource | null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -153,7 +188,7 @@ function rowToItem(row: InventoryRow): IssueInventoryItem { const DEFAULT_MAX_ROUNDS = 5; -function computeConvergenceStatus(items: IssueInventoryItem[], maxRounds: number = DEFAULT_MAX_ROUNDS): ConvergenceStatus { +export function computeConvergenceStatus(items: IssueInventoryItem[], maxRounds: number = DEFAULT_MAX_ROUNDS): ConvergenceStatus { let totalNew = 0; let totalFixed = 0; let totalDismissed = 0; @@ -203,6 +238,134 @@ function computeConvergenceStatus(items: IssueInventoryItem[], maxRounds: number }; } +type ConvergenceRuntimeRow = { + pr_id: string; + auto_converge_enabled: number; + status: string; + poller_status: string; + current_round: number; + active_session_id: string | null; + active_lane_id: string | null; + active_href: string | null; + pause_reason: string | null; + error_message: string | null; + last_started_at: string | null; + last_polled_at: string | null; + last_paused_at: string | null; + last_stopped_at: string | null; + created_at: string; + updated_at: string; +}; + +function buildDefaultRuntimeState(prId: string): ConvergenceRuntimeState { + const now = nowIso(); + return { + prId, + ...DEFAULT_CONVERGENCE_RUNTIME_STATE, + createdAt: now, + updatedAt: now, + }; +} + +/** + * Validates renderer-supplied runtime fields before persisting. + * Throws on clearly malformed data (wrong types, unknown enum values, + * negative rounds) rather than silently correcting. + */ +function validateConvergenceRuntimeState(state: Partial): void { + if (state.autoConvergeEnabled !== undefined && typeof state.autoConvergeEnabled !== "boolean") { + throw new Error(`Invalid autoConvergeEnabled: expected a boolean, got ${JSON.stringify(state.autoConvergeEnabled)}`); + } + if (state.status !== undefined) { + if (typeof state.status !== "string" || !CONVERGENCE_RUNTIME_STATUS_VALUES.has(state.status as ConvergenceRuntimeState["status"])) { + throw new Error(`Invalid convergence runtime status: ${JSON.stringify(state.status)}`); + } + } + if (state.pollerStatus !== undefined) { + if (typeof state.pollerStatus !== "string" || !CONVERGENCE_POLLER_STATUS_VALUES.has(state.pollerStatus as ConvergenceRuntimeState["pollerStatus"])) { + throw new Error(`Invalid convergence poller status: ${JSON.stringify(state.pollerStatus)}`); + } + } + if (state.currentRound !== undefined) { + if (typeof state.currentRound !== "number" || !Number.isFinite(state.currentRound)) { + throw new Error(`Invalid currentRound: expected a finite number, got ${JSON.stringify(state.currentRound)}`); + } + if (state.currentRound < 0 || !Number.isInteger(state.currentRound)) { + throw new Error(`Invalid currentRound: expected a non-negative integer, got ${state.currentRound}`); + } + } + for (const field of [ + "activeSessionId", + "activeLaneId", + "activeHref", + "pauseReason", + "errorMessage", + "lastStartedAt", + "lastPolledAt", + "lastPausedAt", + "lastStoppedAt", + "createdAt", + "updatedAt", + ] as const) { + const value = state[field]; + if (value != null && typeof value !== "string") { + throw new Error(`Invalid ${field}: expected a string, got ${JSON.stringify(value)}`); + } + } +} + +function trimOrNull(value: string | null | undefined): string | null { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function sanitizeConvergenceRuntimeState( + prId: string, + state: ConvergenceRuntimeState, +): ConvergenceRuntimeState { + const now = nowIso(); + return { + prId, + autoConvergeEnabled: state.autoConvergeEnabled, + status: state.status, + pollerStatus: state.pollerStatus, + currentRound: state.currentRound, + activeSessionId: trimOrNull(state.activeSessionId), + activeLaneId: trimOrNull(state.activeLaneId), + activeHref: trimOrNull(state.activeHref), + pauseReason: trimOrNull(state.pauseReason), + errorMessage: trimOrNull(state.errorMessage), + lastStartedAt: trimOrNull(state.lastStartedAt), + lastPolledAt: trimOrNull(state.lastPolledAt), + lastPausedAt: trimOrNull(state.lastPausedAt), + lastStoppedAt: trimOrNull(state.lastStoppedAt), + createdAt: trimOrNull(state.createdAt) ?? now, + updatedAt: trimOrNull(state.updatedAt) ?? now, + }; +} + +function rowToConvergenceRuntime(row: ConvergenceRuntimeRow): ConvergenceRuntimeState { + return sanitizeConvergenceRuntimeState(row.pr_id, { + prId: row.pr_id, + autoConvergeEnabled: row.auto_converge_enabled === 1, + status: row.status as ConvergenceRuntimeState["status"], + pollerStatus: row.poller_status as ConvergenceRuntimeState["pollerStatus"], + currentRound: row.current_round, + activeSessionId: row.active_session_id, + activeLaneId: row.active_lane_id, + activeHref: row.active_href, + pauseReason: row.pause_reason, + errorMessage: row.error_message, + lastStartedAt: row.last_started_at, + lastPolledAt: row.last_polled_at, + lastPausedAt: row.last_paused_at, + lastStoppedAt: row.last_stopped_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +} + // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- @@ -217,6 +380,43 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { ); } + /** + * Returns the highest round number found among inventory items for the given + * PR. Used by {@link computeEffectiveRuntime} to prevent the persisted + * `currentRound` from regressing below what the items imply. + */ + function getMaxItemRound(prId: string): number { + const row = db.get<{ max_round: number | null }>( + "select max(round) as max_round from pr_issue_inventory where pr_id = ?", + [prId], + ); + return row?.max_round ?? 0; + } + + /** + * Merges a persisted (or default) runtime state with an optional patch, + * ensuring `currentRound` never regresses below the highest round implied + * by inventory items. Without this floor the raw persisted value can be + * stale when items have been ingested at a higher round but the runtime row + * was not yet updated. + */ + function computeEffectiveRuntime( + persisted: ConvergenceRuntimeState, + patch?: Partial, + ): ConvergenceRuntimeState { + const itemRound = getMaxItemRound(persisted.prId); + const candidateRound = Math.max( + persisted.currentRound, + itemRound, + patch?.currentRound ?? 0, + ); + return { + ...persisted, + ...patch, + currentRound: candidateRound, + }; + } + function getItemsByState(prId: string, state: IssueInventoryState): IssueInventoryItem[] { return db.all( "select * from pr_issue_inventory where pr_id = ? and state = ? order by created_at asc", @@ -254,44 +454,196 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { body: string | null; author: string | null; url: string | null; + threadCommentCount?: number | null; + threadLatestCommentId?: string | null; + threadLatestCommentAuthor?: string | null; + threadLatestCommentAt?: string | null; + threadLatestCommentSource?: IssueSource | null; }, + options: { + state?: IssueInventoryState; + round?: number; + dismissReason?: string | null; + agentSessionId?: string | null; + } = {}, ): void { const now = nowIso(); const existing = db.get( "select * from pr_issue_inventory where pr_id = ? and external_id = ?", [prId, externalId], ); + const nextState = options.state ?? existing?.state ?? "new"; + const nextRound = options.round ?? existing?.round ?? 0; + const nextDismissReason = options.dismissReason !== undefined + ? options.dismissReason + : existing?.dismiss_reason ?? null; + const nextAgentSessionId = options.agentSessionId !== undefined + ? options.agentSessionId + : existing?.agent_session_id ?? null; + const nextThreadCommentCount = data.threadCommentCount ?? existing?.thread_comment_count ?? null; + const nextThreadLatestCommentId = data.threadLatestCommentId ?? existing?.thread_latest_comment_id ?? null; + const nextThreadLatestCommentAuthor = data.threadLatestCommentAuthor ?? existing?.thread_latest_comment_author ?? null; + const nextThreadLatestCommentAt = data.threadLatestCommentAt ?? existing?.thread_latest_comment_at ?? null; + const nextThreadLatestCommentSource = data.threadLatestCommentSource ?? (existing?.thread_latest_comment_source as IssueSource | null) ?? null; + if (existing) { - // Update mutable fields but keep state db.run( `update pr_issue_inventory set headline = ?, body = ?, severity = ?, file_path = ?, line = ?, - author = ?, url = ?, source = ?, updated_at = ? + author = ?, url = ?, source = ?, state = ?, round = ?, dismiss_reason = ?, + agent_session_id = ?, thread_comment_count = ?, thread_latest_comment_id = ?, + thread_latest_comment_author = ?, thread_latest_comment_at = ?, + thread_latest_comment_source = ?, updated_at = ? where id = ?`, - [data.headline, data.body, data.severity, data.filePath, data.line, - data.author, data.url, data.source, now, existing.id], + [ + data.headline, + data.body, + data.severity, + data.filePath, + data.line, + data.author, + data.url, + data.source, + nextState, + nextRound, + nextDismissReason, + nextAgentSessionId, + nextThreadCommentCount, + nextThreadLatestCommentId, + nextThreadLatestCommentAuthor, + nextThreadLatestCommentAt, + nextThreadLatestCommentSource, + now, + existing.id, + ], ); } else { db.run( `insert into pr_issue_inventory (id, pr_id, source, type, external_id, state, round, file_path, line, severity, headline, body, author, url, dismiss_reason, agent_session_id, - created_at, updated_at) - values (?, ?, ?, ?, ?, 'new', 0, ?, ?, ?, ?, ?, ?, ?, null, null, ?, ?)`, - [randomUUID(), prId, data.source, data.type, externalId, - data.filePath, data.line, data.severity, data.headline, data.body, - data.author, data.url, now, now], + thread_comment_count, thread_latest_comment_id, thread_latest_comment_author, + thread_latest_comment_at, thread_latest_comment_source, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + randomUUID(), + prId, + data.source, + data.type, + externalId, + nextState, + nextRound, + data.filePath, + data.line, + data.severity, + data.headline, + data.body, + data.author, + data.url, + nextDismissReason, + nextAgentSessionId, + nextThreadCommentCount, + nextThreadLatestCommentId, + nextThreadLatestCommentAuthor, + nextThreadLatestCommentAt, + nextThreadLatestCommentSource, + now, + now, + ], ); } } + function getConvergenceRuntimeRow(prId: string): ConvergenceRuntimeRow | null { + return db.get( + "select * from pr_convergence_state where pr_id = ?", + [prId], + ); + } + + function getConvergenceRuntimeRowsByActiveSessionId(sessionId: string): ConvergenceRuntimeRow[] { + return db.all( + "select * from pr_convergence_state where active_session_id = ?", + [sessionId], + ); + } + + function saveConvergenceRuntimeState(prId: string, state: Partial): ConvergenceRuntimeState { + validateConvergenceRuntimeState(state); + const existing = readConvergenceRuntime(prId); + // computeEffectiveRuntime ensures currentRound never regresses below the + // inventory-derived round, even if the incoming patch carries a stale value. + const effective = computeEffectiveRuntime(existing, state); + const merged = sanitizeConvergenceRuntimeState(prId, { + ...effective, + prId, + createdAt: existing.createdAt, + updatedAt: nowIso(), + }); + + db.run( + `insert into pr_convergence_state + (pr_id, auto_converge_enabled, status, poller_status, current_round, active_session_id, + active_lane_id, active_href, pause_reason, error_message, last_started_at, last_polled_at, + last_paused_at, last_stopped_at, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(pr_id) do update set + auto_converge_enabled = excluded.auto_converge_enabled, + status = excluded.status, + poller_status = excluded.poller_status, + current_round = excluded.current_round, + active_session_id = excluded.active_session_id, + active_lane_id = excluded.active_lane_id, + active_href = excluded.active_href, + pause_reason = excluded.pause_reason, + error_message = excluded.error_message, + last_started_at = excluded.last_started_at, + last_polled_at = excluded.last_polled_at, + last_paused_at = excluded.last_paused_at, + last_stopped_at = excluded.last_stopped_at, + updated_at = excluded.updated_at`, + [ + merged.prId, + merged.autoConvergeEnabled ? 1 : 0, + merged.status, + merged.pollerStatus, + merged.currentRound, + merged.activeSessionId, + merged.activeLaneId, + merged.activeHref, + merged.pauseReason, + merged.errorMessage, + merged.lastStartedAt, + merged.lastPolledAt, + merged.lastPausedAt, + merged.lastStoppedAt, + merged.createdAt, + merged.updatedAt, + ], + ); + + return merged; + } + + function readConvergenceRuntime(prId: string): ConvergenceRuntimeState { + const row = getConvergenceRuntimeRow(prId); + const persisted = row ? rowToConvergenceRuntime(row) : buildDefaultRuntimeState(prId); + return computeEffectiveRuntime(persisted); + } + function buildSnapshot(prId: string): IssueInventorySnapshot { const items = getAllRows(prId).map(rowToItem); const { maxRounds } = readPipelineSettings(prId); + const convergence = computeConvergenceStatus(items, maxRounds); + const runtime = readConvergenceRuntime(prId); return { prId, items, - convergence: computeConvergenceStatus(items, maxRounds), + convergence, + runtime: { + ...runtime, + currentRound: Math.max(runtime.currentRound, convergence.currentRound), + }, }; } @@ -302,10 +654,17 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { reviewThreads: PrReviewThread[], comments: PrComment[], ): IssueInventorySnapshot { + const existingRows = getAllRows(prId); + const existingByExternalId = new Map(existingRows.map((row) => [row.external_id, row] as const)); + const activeFailingChecks = new Set(); + // Sync failing checks for (const check of checks) { if (check.conclusion !== "failure") continue; - upsertItem(prId, `check:${check.name}`, { + const externalId = `check:${check.name}`; + activeFailingChecks.add(externalId); + const existing = existingByExternalId.get(externalId) ?? null; + upsertItem(prId, externalId, { source: "unknown", type: "check_failure", filePath: null, @@ -315,25 +674,98 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { body: check.detailsUrl ? `Details: ${check.detailsUrl}` : null, author: null, url: check.detailsUrl, + }, existing?.state === "fixed" ? { + state: "new", + round: 0, + dismissReason: null, + agentSessionId: null, + } : { + state: existing?.state as IssueInventoryState | undefined, }); } - // Sync unresolved, non-outdated review threads + for (const existing of existingRows) { + if (existing.type !== "check_failure") continue; + if (activeFailingChecks.has(existing.external_id)) continue; + if (existing.state === "fixed" || existing.state === "dismissed") continue; + db.run( + "update pr_issue_inventory set state = 'fixed', updated_at = ? where id = ?", + [nowIso(), existing.id], + ); + } + + // Sync review threads using the latest reply in the conversation. for (const thread of reviewThreads) { - if (thread.isResolved || thread.isOutdated) continue; - const firstComment = thread.comments[0] ?? null; - const author = firstComment?.author ?? null; - const body = firstComment?.body ?? null; - upsertItem(prId, `thread:${thread.id}`, { - source: detectSource(author), - type: "review_thread", + const externalId = `thread:${thread.id}`; + const latestComment = thread.comments.at(-1) ?? null; + const commentCount = thread.comments.length; + const author = latestComment?.author ?? null; + const body = latestComment?.body ?? null; + const source = detectSource(author); + const existing = existingByExternalId.get(externalId) ?? null; + + const threadData = { + source, + type: "review_thread" as const, filePath: thread.path, line: thread.line, severity: extractSeverity(body ?? ""), headline: extractHeadline(body, `Review thread at ${thread.path ?? "unknown"}`), body, author, - url: thread.url ?? firstComment?.url ?? null, + url: thread.url ?? latestComment?.url ?? null, + threadCommentCount: commentCount, + threadLatestCommentId: latestComment?.id ?? existing?.thread_latest_comment_id ?? null, + threadLatestCommentAuthor: author, + threadLatestCommentAt: latestComment?.updatedAt ?? latestComment?.createdAt ?? thread.updatedAt, + threadLatestCommentSource: source, + }; + + if (thread.isResolved || thread.isOutdated) { + if (!existing) continue; + upsertItem(prId, externalId, threadData, { + state: "fixed", + round: existing.round, + dismissReason: null, + agentSessionId: existing.agent_session_id, + }); + continue; + } + + const latestCommentAt = latestComment?.updatedAt ?? latestComment?.createdAt ?? null; + const hasStoredThreadMetadata = existing != null + && ( + existing.thread_latest_comment_at != null + || existing.thread_latest_comment_id != null + || existing.thread_comment_count != null + ); + const threadChanged = existing != null + && ( + hasStoredThreadMetadata + ? ( + commentCount > (existing.thread_comment_count ?? 0) + || (latestComment?.id != null && latestComment.id !== existing.thread_latest_comment_id) + || (latestCommentAt != null && existing.thread_latest_comment_at != null + && latestCommentAt > existing.thread_latest_comment_at) + ) + : ( + existing.body !== threadData.body + || existing.headline !== threadData.headline + || existing.author !== threadData.author + ) + ); + const shouldReopen = !existing || (threadChanged && source !== "ade"); + + upsertItem(prId, externalId, threadData, shouldReopen ? { + state: "new", + round: existing?.round ?? 0, + dismissReason: null, + agentSessionId: null, + } : { + state: existing?.state as IssueInventoryState | undefined, + round: existing?.round, + dismissReason: existing?.dismiss_reason, + agentSessionId: existing?.agent_session_id, }); } @@ -415,6 +847,51 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { resetInventory(prId: string): void { db.run("delete from pr_issue_inventory where pr_id = ?", [prId]); + db.run("delete from pr_convergence_state where pr_id = ?", [prId]); + }, + + getConvergenceRuntime(prId: string): ConvergenceRuntimeState { + return readConvergenceRuntime(prId); + }, + + saveConvergenceRuntime(prId: string, state: Partial): ConvergenceRuntimeState { + return saveConvergenceRuntimeState(prId, state); + }, + + reconcileConvergenceSessionExit(sessionId: string, opts?: { exitCode?: number | null }): ConvergenceRuntimeState[] { + const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : ""; + if (!normalizedSessionId) return []; + const rows = getConvergenceRuntimeRowsByActiveSessionId(normalizedSessionId); + if (rows.length === 0) return []; + const endedAt = nowIso(); + const exitCode = typeof opts?.exitCode === "number" ? opts.exitCode : null; + return rows.map((row) => { + const runtime = rowToConvergenceRuntime(row); + if (runtime.autoConvergeEnabled) { + return saveConvergenceRuntimeState(runtime.prId, { + status: "paused", + pollerStatus: "paused", + activeSessionId: null, + pauseReason: "Agent session ended. Refresh the PR to reconcile checks and continue.", + errorMessage: null, + lastPausedAt: endedAt, + }); + } + return saveConvergenceRuntimeState(runtime.prId, { + status: exitCode === 0 ? "stopped" : "failed", + pollerStatus: "stopped", + activeSessionId: null, + pauseReason: null, + errorMessage: exitCode === 0 + ? null + : `Agent session ended with exit code ${exitCode ?? "unknown"}. Refresh the PR to inspect the result.`, + lastStoppedAt: endedAt, + }); + }); + }, + + resetConvergenceRuntime(prId: string): void { + db.run("delete from pr_convergence_state where pr_id = ?", [prId]); }, // ----- Pipeline settings (auto-converge / auto-merge) ----- diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts index e7d6fcdd4..c29330145 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts @@ -71,6 +71,14 @@ function makeDetail(overrides: Partial = {}): PrDetail { }; } +const WORKFLOW_PR_TOOL_NAMES = [ + "prRefreshIssueInventory", + "prGetReviewComments", + "prRerunFailedChecks", + "prReplyToReviewThread", + "prResolveReviewThread", +]; + describe("buildPrIssueResolutionPrompt", () => { it("includes scope, issue inventory, extra instructions, and regression guidance", () => { const prompt = buildPrIssueResolutionPrompt({ @@ -156,11 +164,13 @@ describe("buildPrIssueResolutionPrompt", () => { expect(prompt).toContain("Selected scope: checks and review comments"); expect(prompt).toContain("ADE PR id (for ADE tools): pr-80"); - expect(prompt).toContain("Runtime: Unified/API chat with ADE workflow tools"); + expect(prompt).toContain("Runtime: Workflow chat with ADE PR tools"); expect(prompt).toContain("Please keep the PR description accurate if behavior changes."); expect(prompt).toContain("Watch carefully for regressions caused by your fixes."); expect(prompt).toContain("update the test"); expect(prompt).toContain("rerun the complete failing test files or suites locally"); + expect(prompt).toContain("Commit the changes and push the PR branch before you stop."); + expect(prompt).toContain("If you cannot safely commit or push the necessary changes"); expect(prompt).toContain("prRefreshIssueInventory"); expect(prompt).toContain("prGetReviewComments"); expect(prompt).toContain("thread-1"); @@ -282,8 +292,18 @@ describe("launchPrIssueResolutionChat", () => { const pr = makePr(); const createSession = vi.fn(async () => ({ id: "session-1" })); const sendMessage = vi.fn(async () => undefined); + const previewSessionToolNames = vi.fn(() => WORKFLOW_PR_TOOL_NAMES); const updateMeta = vi.fn(); + const issueInventoryService = { + syncFromPrData: vi.fn(() => ({ + items: [], + convergence: { currentRound: 0, status: "idle" }, + })), + getNewItems: vi.fn(() => []), + markSentToAgent: vi.fn(), + }; + const deps = { prService: { listAll: () => [pr], @@ -298,11 +318,12 @@ describe("launchPrIssueResolutionChat", () => { list: vi.fn(async () => [lane]), getLaneBaseAndBranch: vi.fn(() => ({ baseRef: "main", branchRef: "feature/pr-80", worktreePath: lane.worktreePath, laneType: "worktree" })), }, - agentChatService: { createSession, sendMessage }, + agentChatService: { createSession, sendMessage, previewSessionToolNames }, sessionService: { updateMeta }, + issueInventoryService, }; - return { lane, pr, deps, createSession, sendMessage, updateMeta }; + return { lane, pr, deps, createSession, sendMessage, previewSessionToolNames, updateMeta }; } it("previews the exact first prompt without creating a chat session", async () => { @@ -336,9 +357,17 @@ describe("launchPrIssueResolutionChat", () => { }); expect(result.prompt).toContain("Runtime: Codex chat via ADE MCP"); - expect(result.prompt).toContain("pr_refresh_issue_inventory"); - expect(result.prompt).toContain("pr_get_review_comments"); - expect(result.prompt).toContain("pr_resolve_review_thread"); + expect(result.prompt).toContain("mcp__ade__pr_refresh_issue_inventory"); + expect(result.prompt).toContain("mcp__ade__pr_get_review_comments"); + expect(result.prompt).toContain("mcp__ade__pr_resolve_review_thread"); + expect(result.prompt).toContain("ADE PR tools are runtime tool calls, not shell commands."); + expect(result.prompt).toContain("Some bridges may also expose the base tool names like `pr_refresh_issue_inventory` and `pr_get_review_comments`."); + expect(result.prompt).toContain("Use whichever variant is actually exposed in the live tool list for this chat runtime."); + expect(result.prompt).toContain("Immediately after that, call `mcp__ade__pr_get_review_comments`"); + expect(result.prompt).toContain("Treat the refreshed inventory as a triage index"); + expect(result.prompt).toContain("Do not spend your first steps reading local skill docs"); + expect(result.prompt).toContain("Do not probe tool availability with `which`, `command -v`, `.mcp.json`, or project settings files"); + expect(result.prompt).toContain("Do not conclude the PR tools are missing just because one naming variant is absent."); expect(result.prompt).not.toContain("prRefreshIssueInventory"); }); @@ -356,7 +385,8 @@ describe("launchPrIssueResolutionChat", () => { expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ laneId: lane.id, - provider: "unified", + provider: "codex", + model: "gpt-5.4", modelId: "openai/gpt-5.4-codex", surface: "work", sessionProfile: "workflow", @@ -376,6 +406,23 @@ describe("launchPrIssueResolutionChat", () => { }); }); + it("fails fast when an API workflow chat does not expose required PR tools", async () => { + const { deps, pr, createSession, sendMessage } = makeDeps(); + deps.agentChatService.previewSessionToolNames = vi.fn(() => ["prGetChecks"]); + + await expect(launchPrIssueResolutionChat(deps as any, { + prId: pr.id, + scope: "checks", + modelId: "openai/gpt-5.4", + reasoning: "high", + permissionMode: "guarded_edit", + additionalInstructions: null, + })).rejects.toThrow("PR issue resolver requires ADE PR tools"); + + expect(createSession).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("rejects checks scope while checks are still running", async () => { const runningCheck: PrCheck = { name: "ci / unit", status: "in_progress", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null }; const { pr, deps } = makeDeps({ checks: [runningCheck] }); @@ -433,8 +480,20 @@ describe("launchPrIssueResolutionChat", () => { list: vi.fn(async () => [lane]), getLaneBaseAndBranch: vi.fn(() => ({ baseRef: "main", branchRef: "feature/pr-80", worktreePath: lane.worktreePath, laneType: "worktree" })), }, - agentChatService: { createSession, sendMessage }, + agentChatService: { + createSession, + sendMessage, + previewSessionToolNames: vi.fn(() => WORKFLOW_PR_TOOL_NAMES), + }, sessionService: { updateMeta: vi.fn() }, + issueInventoryService: { + syncFromPrData: vi.fn(() => ({ + items: [], + convergence: { currentRound: 0, status: "idle" }, + })), + getNewItems: vi.fn(() => []), + markSentToAgent: vi.fn(), + }, }; await launchPrIssueResolutionChat(deps as any, { @@ -449,7 +508,7 @@ describe("launchPrIssueResolutionChat", () => { expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-claude", executionMode: "subagents", - text: expect.stringContaining("Runtime: Claude SDK chat via ADE MCP"), + text: expect.stringContaining("Runtime: Claude chat via ADE MCP"), })); expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ text: expect.stringContaining("Current unresolved review threads (detailed context)"), diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.ts b/apps/desktop/src/main/services/prs/prIssueResolver.ts index 9218682fb..841f15931 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { getModelById } from "../../../shared/modelRegistry"; +import { getModelById, resolveChatProviderForDescriptor } from "../../../shared/modelRegistry"; import type { AgentChatExecutionMode, IssueInventoryItem, @@ -22,7 +22,7 @@ import type { createLaneService } from "../lanes/laneService"; import type { createPrService } from "./prService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createSessionService } from "../sessions/sessionService"; -import type { createIssueInventoryService } from "./issueInventoryService"; +import { computeConvergenceStatus, type createIssueInventoryService } from "./issueInventoryService"; import { isNoisyIssueComment, mapPermissionMode, readRecentCommits } from "./resolverUtils"; type PreviouslyHandledSummary = { @@ -33,6 +33,12 @@ type PreviouslyHandledSummary = { dismissedHeadlines: string[]; }; +function isInventoryItemInScope(item: IssueInventoryItem, scope: PrIssueResolutionScope): boolean { + if (scope === "checks") return item.type === "check_failure"; + if (scope === "comments") return item.type === "review_thread" || item.type === "issue_comment"; + return true; +} + type IssueResolutionPromptArgs = { pr: PrSummary; lane: LaneSummary; @@ -71,9 +77,9 @@ type PrIssueResolutionRuntimeCapabilities = { export type PrIssueResolutionLaunchDeps = { prService: ReturnType; laneService: Pick, "list" | "getLaneBaseAndBranch">; - agentChatService: Pick, "createSession" | "sendMessage">; + agentChatService: Pick, "createSession" | "sendMessage" | "previewSessionToolNames">; sessionService: Pick, "updateMeta">; - issueInventoryService?: ReturnType | null; + issueInventoryService: ReturnType; }; type PreparedIssueResolutionPrompt = { @@ -325,7 +331,7 @@ function buildSelectedScopeDescription(scope: PrIssueResolutionScope): string { function defaultPrIssueResolutionRuntimeCapabilities(): PrIssueResolutionRuntimeCapabilities { return { - runtimeLabel: "Unified/API chat with ADE workflow tools", + runtimeLabel: "Workflow chat with ADE PR tools", toolSurface: "workflow_tools", refreshInventoryTool: "prRefreshIssueInventory", getReviewCommentsTool: "prGetReviewComments", @@ -336,41 +342,61 @@ function defaultPrIssueResolutionRuntimeCapabilities(): PrIssueResolutionRuntime }; } +const ADE_MCP_SERVER_NAME = "ade"; + +function qualifyAdeMcpToolName(toolName: string): string { + return `mcp__${ADE_MCP_SERVER_NAME}__${toolName}`; +} + +function unqualifyAdeMcpToolName(toolName: string | null): string | null { + if (!toolName) return null; + return toolName.replace(/^mcp__[^_]+__/, ""); +} + +function buildMcpToolCapabilities( + runtimeLabel: string, + executionMode: PrIssueResolutionRuntimeCapabilities["executionMode"], +): PrIssueResolutionRuntimeCapabilities { + return { + refreshInventoryTool: qualifyAdeMcpToolName("pr_refresh_issue_inventory"), + getReviewCommentsTool: qualifyAdeMcpToolName("pr_get_review_comments"), + rerunChecksTool: qualifyAdeMcpToolName("pr_rerun_failed_checks"), + replyThreadTool: qualifyAdeMcpToolName("pr_reply_to_review_thread"), + resolveThreadTool: qualifyAdeMcpToolName("pr_resolve_review_thread"), + runtimeLabel, + toolSurface: "ade_mcp", + executionMode, + }; +} + function resolvePrIssueResolutionRuntimeCapabilities(modelId: string | null | undefined): PrIssueResolutionRuntimeCapabilities { const descriptor = modelId ? getModelById(modelId) : null; - if (!descriptor || !descriptor.isCliWrapped) { + if (!descriptor) { return defaultPrIssueResolutionRuntimeCapabilities(); } - const MCP_TOOLS = { - refreshInventoryTool: "pr_refresh_issue_inventory", - getReviewCommentsTool: "pr_get_review_comments", - rerunChecksTool: "pr_rerun_failed_checks", - replyThreadTool: "pr_reply_to_review_thread", - resolveThreadTool: "pr_resolve_review_thread", - } as const; - - if (descriptor.family === "openai") { - return { - ...MCP_TOOLS, - runtimeLabel: "Codex chat via ADE MCP", - toolSurface: "ade_mcp", - executionMode: "parallel", - }; + if (descriptor.isCliWrapped && descriptor.family === "openai") { + return buildMcpToolCapabilities("Codex chat via ADE MCP", "parallel"); } - if (descriptor.family === "anthropic") { - return { - ...MCP_TOOLS, - runtimeLabel: "Claude SDK chat via ADE MCP", - toolSurface: "ade_mcp", - executionMode: "subagents", - }; + if (descriptor.isCliWrapped && descriptor.family === "anthropic") { + return buildMcpToolCapabilities("Claude chat via ADE MCP", "subagents"); } return defaultPrIssueResolutionRuntimeCapabilities(); } +function listRequiredRuntimeTools(runtimeCapabilities: PrIssueResolutionRuntimeCapabilities): string[] { + if (runtimeCapabilities.toolSurface === "prompt_only") return []; + return [ + runtimeCapabilities.refreshInventoryTool, + runtimeCapabilities.getReviewCommentsTool, + runtimeCapabilities.rerunChecksTool, + runtimeCapabilities.replyThreadTool, + runtimeCapabilities.resolveThreadTool, + ].filter((toolName): toolName is string => typeof toolName === "string" && toolName.trim().length > 0); +} + export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): string { const actionableThreads = args.reviewThreads.filter((thread) => !thread.isResolved && !thread.isOutdated); const availability = getPrIssueResolutionAvailability(args.checks, args.reviewThreads); @@ -429,7 +455,7 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s if (useInventory) { promptSections.push( "", - "Current issues to address (from inventory — NEW items only)", + "Current issues to address (from inventory)", formatInventoryItemsSummary(args.inventoryItems!), ); } else { @@ -499,6 +525,9 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s "", "Requirements", "- Fix all valid issues in the selected scope, not just the first one.", + "- If you make local code or git changes that should affect the PR, do not finish with local-only state. Commit the changes and push the PR branch before you stop.", + "- If you only resolve stale review threads or other PR metadata with ADE tools and no local git changes are needed, say that clearly in your final note.", + "- If you cannot safely commit or push the necessary changes, stop with a concrete blocker instead of exiting as if the round succeeded.", ); if (runtimeCapabilities.toolSurface === "prompt_only") { @@ -506,18 +535,31 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s "- No live ADE PR tools are available in this session. Use the detailed issue context in this prompt plus the linked GitHub thread/check URLs.", "- If you need fresher PR state than this prompt provides, fetch it manually before making changes.", ); + } else if (runtimeCapabilities.toolSurface === "ade_mcp") { + const toolList = listRequiredRuntimeTools(runtimeCapabilities).map((toolName) => `\`${toolName}\``).join(", "); + const refreshToolFallback = unqualifyAdeMcpToolName(runtimeCapabilities.refreshInventoryTool); + const commentsToolFallback = unqualifyAdeMcpToolName(runtimeCapabilities.getReviewCommentsTool); + promptSections.push( + `- This runtime uses ADE via MCP. ADE PR tools are runtime tool calls, not shell commands.`, + `- Primary PR tools for this run: ${toolList}. In many sessions they appear namespaced with the MCP server prefix, for example \`${runtimeCapabilities.refreshInventoryTool}\`. Some bridges may also expose the base tool names like \`${refreshToolFallback}\` and \`${commentsToolFallback}\`.`, + "- Use whichever variant is actually exposed in the live tool list for this chat runtime.", + `- Start by refreshing the PR issue inventory with \`${runtimeCapabilities.refreshInventoryTool}\`.`, + `- Immediately after that, call \`${runtimeCapabilities.getReviewCommentsTool}\` to load the full review-thread bodies and line context before deciding which comments are stale, valid, or already addressed.`, + "- Treat the refreshed inventory as a triage index, not as the full source of truth for long comment bodies. If a summary looks compact or truncated, fetch the detailed review comments instead of guessing.", + "- Do not spend your first steps reading local skill docs, repo docs, or unrelated files before those PR context calls succeed.", + "- Do not probe tool availability with `which`, `command -v`, `.mcp.json`, or project settings files from inside the task session.", + "- If one of those MCP tools is unavailable in-session, continue with the prompt's issue context and the linked GitHub thread/check URLs instead of reverse-engineering local MCP wiring.", + "- Do not conclude the PR tools are missing just because one naming variant is absent.", + ); } else { - const surfaceLabel = runtimeCapabilities.toolSurface === "ade_mcp" ? "ADE MCP tool" : "ADE workflow tool"; - const toolList = [ - runtimeCapabilities.refreshInventoryTool, - runtimeCapabilities.getReviewCommentsTool, - runtimeCapabilities.rerunChecksTool, - runtimeCapabilities.replyThreadTool, - runtimeCapabilities.resolveThreadTool, - ].filter(Boolean).map((t) => `\`${t}\``).join(", "); + const toolList = listRequiredRuntimeTools(runtimeCapabilities).map((toolName) => `\`${toolName}\``).join(", "); promptSections.push( - `- Start by refreshing the PR issue inventory with ${surfaceLabel} \`${runtimeCapabilities.refreshInventoryTool}\`, especially if CI or review state may have changed.`, - `- Use ${surfaceLabel}s instead of assuming GitHub CLI access. Relevant tools include ${toolList}.`, + `- This workflow chat is expected to expose ADE PR tools. Start by refreshing the PR issue inventory with \`${runtimeCapabilities.refreshInventoryTool}\`.`, + `- Immediately after that, call \`${runtimeCapabilities.getReviewCommentsTool}\` to load the full review-thread bodies and line context before deciding which comments are stale, valid, or already addressed.`, + "- Treat the refreshed inventory as a triage index, not as the full source of truth for long comment bodies. If a summary looks compact or truncated, fetch the detailed review comments instead of guessing.", + "- Do not spend your first steps reading local skill docs, repo docs, or unrelated files before those PR context calls succeed.", + `- Required PR tools for this run: ${toolList}. If any of them are unavailable, stop and report that the chat was launched without the required ADE PR tools.`, + "- Do not waste time reverse-engineering local MCP wiring or local server bootstraps from inside the task session.", ); } @@ -588,34 +630,33 @@ async function preparePrIssueResolutionPrompt( throw new Error("Checks and comments are no longer both actionable. Refresh the PR and choose the currently available scope."); } - // If inventory service is available, sync and build incremental context - const inventoryService = deps.issueInventoryService ?? null; + // Sync inventory and build incremental context + const inventoryService = deps.issueInventoryService; let previouslyHandled: PreviouslyHandledSummary | null = null; let roundNumber: number | null = null; let inventoryNewItems: IssueInventoryItem[] | undefined; const runtimeCapabilities = resolvePrIssueResolutionRuntimeCapabilities(args.modelId); - if (inventoryService) { - // Sync the inventory with fresh GitHub data - const snapshot = inventoryService.syncFromPrData(pr.id, checks, reviewThreads, comments); - const convergence = snapshot.convergence; - roundNumber = convergence.currentRound + 1; - inventoryNewItems = inventoryService.getNewItems(pr.id); - - // Build summary of previously handled items across all prior rounds - if (convergence.currentRound > 0) { - const fixedItems = snapshot.items.filter((i) => i.state === "fixed"); - const dismissedItems = snapshot.items.filter((i) => i.state === "dismissed"); - const escalatedItems = snapshot.items.filter((i) => i.state === "escalated"); - - previouslyHandled = { - fixedCount: fixedItems.length, - dismissedCount: dismissedItems.length, - escalatedCount: escalatedItems.length, - fixedHeadlines: fixedItems.map((i) => i.headline), - dismissedHeadlines: dismissedItems.map((i) => i.headline), - }; - } + // Sync the inventory with fresh GitHub data + const snapshot = inventoryService.syncFromPrData(pr.id, checks, reviewThreads, comments); + const scopedInventoryItems = snapshot.items.filter((item) => isInventoryItemInScope(item, args.scope)); + const convergence = computeConvergenceStatus(scopedInventoryItems, snapshot.convergence.maxRounds); + roundNumber = convergence.currentRound + 1; + inventoryNewItems = scopedInventoryItems.filter((item) => item.state === "new"); + + // Build summary of previously handled items across all prior rounds + if (convergence.currentRound > 0) { + const fixedItems = scopedInventoryItems.filter((i) => i.state === "fixed"); + const dismissedItems = scopedInventoryItems.filter((i) => i.state === "dismissed"); + const escalatedItems = scopedInventoryItems.filter((i) => i.state === "escalated"); + + previouslyHandled = { + fixedCount: fixedItems.length, + dismissedCount: dismissedItems.length, + escalatedCount: escalatedItems.length, + fixedHeadlines: fixedItems.map((i) => i.headline), + dismissedHeadlines: dismissedItems.map((i) => i.headline), + }; } return { @@ -635,7 +676,7 @@ async function preparePrIssueResolutionPrompt( recentCommits: await readRecentCommits(lane.worktreePath), round: roundNumber, previouslyHandled, - inventoryItems: inventoryNewItems ?? null, + inventoryItems: scopedInventoryItems.length > 0 ? scopedInventoryItems : null, runtimeCapabilities, detailedIssueContext: options.detailLevel === "launch", }), @@ -669,11 +710,26 @@ export async function launchPrIssueResolutionChat( } const prepared = await preparePrIssueResolutionPrompt(deps, args, { detailLevel: "launch" }); const reasoningEffort = args.reasoning?.trim() || undefined; + const requiredToolNames = listRequiredRuntimeTools(prepared.runtimeCapabilities); + const { provider, model } = resolveChatProviderForDescriptor(descriptor); + + if (requiredToolNames.length > 0 && prepared.runtimeCapabilities.toolSurface === "workflow_tools") { + const availableToolNames = deps.agentChatService.previewSessionToolNames({ + laneId: prepared.lane.id, + sessionProfile: "workflow", + }); + const missingToolNames = requiredToolNames.filter((toolName) => !availableToolNames.includes(toolName)); + if (missingToolNames.length > 0) { + throw new Error( + `PR issue resolver requires ADE PR tools that are unavailable in this chat runtime: ${missingToolNames.join(", ")}.`, + ); + } + } const session = await deps.agentChatService.createSession({ laneId: prepared.lane.id, - provider: "unified", - model: descriptor.id, + provider, + model, modelId: descriptor.id, ...(reasoningEffort ? { reasoningEffort } : {}), permissionMode: mapPermissionMode(args.permissionMode), @@ -691,14 +747,21 @@ export async function launchPrIssueResolutionChat( ...(prepared.runtimeCapabilities.executionMode ? { executionMode: prepared.runtimeCapabilities.executionMode } : {}), }); - // Mark inventory items as sent to agent for this round - if (deps.issueInventoryService && prepared.inventoryNewItems?.length && prepared.roundNumber != null) { - deps.issueInventoryService.markSentToAgent( - prepared.pr.id, - prepared.inventoryNewItems.map((item) => item.id), - session.id, - prepared.roundNumber, - ); + // Mark inventory items as sent to agent for this round (non-fatal — session is already live) + if (prepared.inventoryNewItems?.length && prepared.roundNumber != null) { + try { + deps.issueInventoryService.markSentToAgent( + prepared.pr.id, + prepared.inventoryNewItems.map((item) => item.id), + session.id, + prepared.roundNumber, + ); + } catch (err) { + console.warn( + `[prIssueResolver] Failed to mark ${prepared.inventoryNewItems.length} inventory items as sent to agent for PR ${prepared.pr.id} round ${prepared.roundNumber}:`, + err, + ); + } } return { diff --git a/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts b/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts index d9c7b53e7..5d77bb684 100644 --- a/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts +++ b/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts @@ -119,7 +119,8 @@ describe("launchRebaseResolutionChat", () => { expect(createSession).toHaveBeenCalledWith( expect.objectContaining({ laneId: lane.id, - provider: "unified", + provider: "claude", + model: "sonnet", modelId: "anthropic/claude-sonnet-4-6", surface: "work", sessionProfile: "workflow", diff --git a/apps/desktop/src/main/services/prs/prRebaseResolver.ts b/apps/desktop/src/main/services/prs/prRebaseResolver.ts index c386e2443..9968d5a93 100644 --- a/apps/desktop/src/main/services/prs/prRebaseResolver.ts +++ b/apps/desktop/src/main/services/prs/prRebaseResolver.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { getModelById } from "../../../shared/modelRegistry"; +import { getModelById, resolveChatProviderForDescriptor } from "../../../shared/modelRegistry"; import type { LaneSummary, RebaseResolutionStartArgs, @@ -139,11 +139,12 @@ export async function launchRebaseResolutionChat( const title = `Rebase ${lane.name} onto ${rebaseNeed.baseBranch}`; const reasoningEffort = args.reasoning?.trim() || undefined; + const { provider, model } = resolveChatProviderForDescriptor(descriptor); const session = await deps.agentChatService.createSession({ laneId: lane.id, - provider: "unified", - model: descriptor.id, + provider, + model, modelId: descriptor.id, ...(reasoningEffort ? { reasoningEffort } : {}), permissionMode: mapPermissionMode(args.permissionMode), diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 69ef3876e..c2b20f2ed 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -1800,6 +1800,11 @@ export function createPrService({ `, [projectId, projectId] ); + // Explicitly delete child rows that rely on FK cascade — CRR conversion can + // strip checked foreign keys, leaving orphaned rows if we only rely on CASCADE. + db.run("delete from pr_convergence_state where pr_id = ?", [row.id]); + db.run("delete from pr_pipeline_settings where pr_id = ?", [row.id]); + db.run("delete from pr_issue_inventory where pr_id = ?", [row.id]); db.run("delete from pull_requests where id = ? and project_id = ?", [row.id, projectId]); let laneArchived = false; diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts index f21259f07..9ddf70d54 100644 --- a/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts +++ b/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts @@ -18,7 +18,6 @@ describe("resolveDesktopAdeMcpLaunch", () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-")); const workspaceRoot = path.join(projectRoot, "workspace"); const proxyEntry = path.join(projectRoot, "dist", "main", "adeMcpProxy.cjs"); - fs.mkdirSync(workspaceRoot, { recursive: true }); fs.mkdirSync(path.dirname(proxyEntry), { recursive: true }); fs.writeFileSync(proxyEntry, "module.exports = {};\n", "utf8"); @@ -138,29 +137,32 @@ describe("resolveDesktopAdeMcpLaunch", () => { expect(launch.runtimeRoot).toBe(path.resolve(runtimeRoot)); }); - it("defaults projectRoot to workspaceRoot when projectRoot is empty or missing", () => { + it("requires an explicit non-empty projectRoot", () => { const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-nopr-")); const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-ws-nopr-")); - const launch = resolveDesktopAdeMcpLaunch({ - workspaceRoot, - runtimeRoot, - preferBundledProxy: false, - }); - - expect(launch.env.ADE_PROJECT_ROOT).toBe(path.resolve(workspaceRoot)); - expect(launch.env.ADE_WORKSPACE_ROOT).toBe(path.resolve(workspaceRoot)); - expect(launch.socketPath).toBe(path.join(path.resolve(workspaceRoot), ".ade", "mcp.sock")); + expect(() => { + // @ts-expect-error: projectRoot is intentionally omitted for this runtime assertion. + resolveDesktopAdeMcpLaunch({ + workspaceRoot, + runtimeRoot, + preferBundledProxy: false, + }); + }).toThrow("ADE MCP launch requires a non-empty projectRoot."); - // Also test with empty string projectRoot - const launchEmpty = resolveDesktopAdeMcpLaunch({ + expect(() => resolveDesktopAdeMcpLaunch({ projectRoot: " ", workspaceRoot, runtimeRoot, preferBundledProxy: false, - }); + })).toThrow("ADE MCP launch requires a non-empty projectRoot."); - expect(launchEmpty.env.ADE_PROJECT_ROOT).toBe(path.resolve(workspaceRoot)); + expect(() => resolveDesktopAdeMcpLaunch({ + projectRoot: workspaceRoot, + workspaceRoot: " ", + runtimeRoot, + preferBundledProxy: false, + })).toThrow("ADE MCP launch requires a non-empty workspaceRoot."); }); it("populates computerUsePolicy env vars when policy is provided", () => { diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts index ad5fa080b..4bd65964a 100644 --- a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts +++ b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts @@ -17,8 +17,8 @@ export type AdeMcpLaunch = { resourcesPath: string | null; }; -type AdeMcpLaunchArgs = { - projectRoot?: string; +export type DesktopAdeMcpLaunchArgs = { + projectRoot: string; workspaceRoot: string; runtimeRoot?: string; missionId?: string; @@ -32,6 +32,14 @@ type AdeMcpLaunchArgs = { preferBundledProxy?: boolean; }; +function resolveRequiredRoot(value: unknown, label: "projectRoot" | "workspaceRoot"): string { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed.length) { + throw new Error(`ADE MCP launch requires a non-empty ${label}.`); + } + return path.resolve(trimmed); +} + function pathExists(targetPath: string | null | undefined): targetPath is string { return Boolean(targetPath && fs.existsSync(targetPath)); } @@ -92,7 +100,7 @@ function buildLaunchEnv(args: { projectRoot: string; workspaceRoot: string; socketPath: string; -} & Pick): Record { +} & Pick): Record { return { ADE_PROJECT_ROOT: args.projectRoot, ADE_WORKSPACE_ROOT: args.workspaceRoot, @@ -116,11 +124,9 @@ function buildLaunchEnv(args: { }; } -export function resolveDesktopAdeMcpLaunch(args: AdeMcpLaunchArgs): AdeMcpLaunch { - const projectRoot = typeof args.projectRoot === "string" && args.projectRoot.trim().length > 0 - ? path.resolve(args.projectRoot) - : path.resolve(args.workspaceRoot); - const workspaceRoot = path.resolve(args.workspaceRoot); +export function resolveDesktopAdeMcpLaunch(args: DesktopAdeMcpLaunchArgs): AdeMcpLaunch { + const projectRoot = resolveRequiredRoot(args.projectRoot, "projectRoot"); + const workspaceRoot = resolveRequiredRoot(args.workspaceRoot, "workspaceRoot"); const socketPath = resolveAdeLayout(projectRoot).socketPath; const resourcesPath = resolveResourcesPath(); const env = buildLaunchEnv({ @@ -161,11 +167,21 @@ export function resolveDesktopAdeMcpLaunch(args: AdeMcpLaunchArgs): AdeMcpLaunch const srcEntry = path.join(mcpServerDir, "src", "index.ts"); if (fs.existsSync(builtEntry)) { + // Native modules (e.g. node-pty) are externalised from the bundle and + // resolved at runtime via require(). When the MCP server runs as a + // plain `node` process (not inside Electron), NODE_PATH must include + // the desktop app's node_modules so those externals can be found. + const desktopNodeModules = path.resolve(runtimeRoot, "apps", "desktop", "node_modules"); + const existingNodePath = env.NODE_PATH ?? process.env.NODE_PATH ?? ""; + const nodePath = existingNodePath + ? `${desktopNodeModules}${path.delimiter}${existingNodePath}` + : desktopNodeModules; + return { mode: "headless_built", command: "node", cmdArgs: [builtEntry, "--project-root", projectRoot, "--workspace-root", workspaceRoot], - env, + env: { ...env, NODE_PATH: nodePath }, entryPath: builtEntry, runtimeRoot, socketPath, diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 28f4017a5..2fda9cca5 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -248,6 +248,7 @@ const FK_CONSTRAINTS: Record = { // PR convergence loop tables "pr_issue_inventory:pr_id": { references: "pull_requests(id)", action: "on delete cascade" }, "pr_pipeline_settings:pr_id": { references: "pull_requests(id)", action: "on delete cascade" }, + "pr_convergence_state:pr_id": { references: "pull_requests(id)", action: "on delete cascade" }, }; /** @@ -2960,6 +2961,11 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { foreign key(pr_id) references pull_requests(id) on delete cascade ) `); + try { db.run("alter table pr_issue_inventory add column thread_comment_count integer"); } catch {} + try { db.run("alter table pr_issue_inventory add column thread_latest_comment_id text"); } catch {} + try { db.run("alter table pr_issue_inventory add column thread_latest_comment_author text"); } catch {} + try { db.run("alter table pr_issue_inventory add column thread_latest_comment_at text"); } catch {} + try { db.run("alter table pr_issue_inventory add column thread_latest_comment_source text"); } catch {} db.run("create index if not exists idx_inventory_pr_state on pr_issue_inventory(pr_id, state)"); // PR pipeline settings: per-PR auto-converge / auto-merge configuration @@ -2974,8 +2980,29 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { foreign key(pr_id) references pull_requests(id) on delete cascade ) `); -} + db.run(` + create table if not exists pr_convergence_state ( + pr_id text primary key, + auto_converge_enabled integer not null default 0, + status text not null default 'idle', + poller_status text not null default 'idle', + current_round integer not null default 0, + active_session_id text, + active_lane_id text, + active_href text, + pause_reason text, + error_message text, + last_started_at text, + last_polled_at text, + last_paused_at text, + last_stopped_at text, + created_at text not null, + updated_at text not null, + foreign key(pr_id) references pull_requests(id) on delete cascade + ) + `); +} function loadCrsqlite(db: DatabaseSyncType, extensionPath: string): void { db.enableLoadExtension(true); @@ -2995,12 +3022,33 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { loadCrsqlite(db, extensionPath); } - migrate({ + // Build a CRR-aware run wrapper: when crsqlite is loaded and a table has + // been converted to a CRR, ALTER TABLE statements must be wrapped with + // crsql_begin_alter / crsql_commit_alter so the clock tables stay in sync. + let crsqliteLoaded = hadCrsqlMetadata && hasCrsqlite; + const makeMigrateDb = () => ({ run: (sql: string, params: SqlValue[] = []) => { + const alterTable = parseAlterTableTarget(sql); + if (alterTable && crsqliteLoaded && rawHasTable(db, `${alterTable}__crsql_clock`)) { + getRow(db, "select crsql_begin_alter(?) as ok", [alterTable]); + try { + runStatement(db, sql, params); + } catch (error) { + // Commit the alter even on failure so the CRR state stays consistent, + // then re-throw so the caller's try/catch can handle it (e.g. column + // already exists on upgrade). + getRow(db, "select crsql_commit_alter(?) as ok", [alterTable]); + throw error; + } + getRow(db, "select crsql_commit_alter(?) as ok", [alterTable]); + return; + } runStatement(db, sql, params); }, }); + migrate(makeMigrateDb()); + if (existedBeforeOpen && !hasCrsqlMetadata(db)) { writeMigrationBackupIfNeeded(dbPath); } @@ -3011,11 +3059,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { if (hadCrsqlMetadata && hasCrsqlite) { loadCrsqlite(db, extensionPath); } - migrate({ - run: (sql: string, params: SqlValue[] = []) => { - runStatement(db, sql, params); - }, - }); + migrate(makeMigrateDb()); } if (retrofitForeignKeyCascadeActions(db, hasCrsqlite)) { @@ -3024,11 +3068,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { if (hadCrsqlMetadata && hasCrsqlite) { loadCrsqlite(db, extensionPath); } - migrate({ - run: (sql: string, params: SqlValue[] = []) => { - runStatement(db, sql, params); - }, - }); + migrate(makeMigrateDb()); } if (hasCrsqlite) { diff --git a/apps/desktop/src/main/services/state/onConflictAudit.test.ts b/apps/desktop/src/main/services/state/onConflictAudit.test.ts index 4bfe7caf4..74ddd46da 100644 --- a/apps/desktop/src/main/services/state/onConflictAudit.test.ts +++ b/apps/desktop/src/main/services/state/onConflictAudit.test.ts @@ -90,6 +90,11 @@ const APPROVED_CONFLICT_TARGETS: ConflictTarget[] = [ table: "process_runtime", columns: "project_id,lane_id,process_key", }, + { + file: "src/main/services/prs/issueInventoryService.ts", + table: "pr_convergence_state", + columns: "pr_id", + }, { file: "src/main/services/prs/issueInventoryService.ts", table: "pr_pipeline_settings", diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index fd6a5cbe8..37f2a3383 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -321,6 +321,8 @@ import type { AiReviewSummary, IssueInventoryItem, IssueInventorySnapshot, + PrConvergenceState, + PrConvergenceStatePatch, ConvergenceStatus, PipelineSettings, UpdateIntegrationProposalArgs, @@ -1030,6 +1032,9 @@ declare global { issueInventoryMarkEscalated: (prId: string, itemIds: string[]) => Promise; issueInventoryGetConvergence: (prId: string) => Promise; issueInventoryReset: (prId: string) => Promise; + convergenceStateGet: (prId: string) => Promise; + convergenceStateSave: (prId: string, state: PrConvergenceStatePatch) => Promise; + convergenceStateDelete: (prId: string) => Promise; pipelineSettingsGet: (prId: string) => Promise; pipelineSettingsSave: (prId: string, settings: Partial) => Promise; pipelineSettingsDelete: (prId: string) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e7ca703ee..b42c899ac 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -207,6 +207,8 @@ import type { AiReviewSummary, IssueInventoryItem, IssueInventorySnapshot, + PrConvergenceState, + PrConvergenceStatePatch, ConvergenceStatus, PipelineSettings, UpdateIntegrationProposalArgs, @@ -1462,6 +1464,12 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.prsIssueInventoryGetConvergence, { prId }), issueInventoryReset: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsIssueInventoryReset, { prId }), + convergenceStateGet: async (prId: string): Promise => + ipcRenderer.invoke(IPC.prsConvergenceStateGet, { prId }), + convergenceStateSave: async (prId: string, state: PrConvergenceStatePatch): Promise => + ipcRenderer.invoke(IPC.prsConvergenceStateSave, { prId, state }), + convergenceStateDelete: async (prId: string): Promise => + ipcRenderer.invoke(IPC.prsConvergenceStateDelete, { prId }), pipelineSettingsGet: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsPipelineSettingsGet, { prId }), pipelineSettingsSave: async (prId: string, settings: Partial): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index f4018cd7f..9620e8735 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -461,6 +461,30 @@ const MOCK_STATUS_BY_PR: Record = { "pr-5": { prId: "pr-5", state: "open", checksStatus: "passing", reviewStatus: "none", isMergeable: true, mergeConflicts: false, behindBaseBy: 3 }, }; +const MOCK_CONVERGENCE_RUNTIME: Record = {}; + +function createDefaultConvergenceRuntime(prId: string) { + const nowIso = new Date().toISOString(); + return { + prId, + autoConvergeEnabled: false, + status: "idle", + pollerStatus: "idle", + currentRound: 0, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: nowIso, + updatedAt: nowIso, + }; +} + // ── Rebase Needs (all urgency categories) ───────────────────── const MOCK_REBASE_NEEDS: any[] = [ // Attention: behind + conflicts predicted @@ -1511,6 +1535,36 @@ if (typeof window !== "undefined" && !(window as any).ade) { laneId: "lane-dashboard", href: "/work?laneId=lane-dashboard&sessionId=mock-pr-issue-session", }), + convergenceStateGet: async (prId: string) => { + const stored = MOCK_CONVERGENCE_RUNTIME[prId] ?? createDefaultConvergenceRuntime(prId); + return { ...stored }; + }, + convergenceStateSave: async (prId: string, state: Record) => { + const nowIso = new Date().toISOString(); + const existing = MOCK_CONVERGENCE_RUNTIME[prId] ?? createDefaultConvergenceRuntime(prId); + // Only allow known ConvergenceRuntimeState keys (mirror real backend validation) + const allowedKeys = new Set([ + "autoConvergeEnabled", "status", "pollerStatus", "currentRound", + "activeSessionId", "activeLaneId", "activeHref", "pauseReason", + "errorMessage", "lastStartedAt", "lastPolledAt", "lastPausedAt", "lastStoppedAt", + ]); + const filtered: Record = {}; + for (const key of Object.keys(state)) { + if (allowedKeys.has(key)) filtered[key] = state[key]; + } + const next = { + ...existing, + ...filtered, + prId, + createdAt: existing.createdAt, + updatedAt: nowIso, + }; + MOCK_CONVERGENCE_RUNTIME[prId] = next; + return { ...next }; + }, + convergenceStateDelete: async (prId: string) => { + delete MOCK_CONVERGENCE_RUNTIME[prId]; + }, rebaseResolutionStart: async () => ({ sessionId: "mock-rebase-session", laneId: "lane-dashboard", diff --git a/apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx b/apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx index ee1f4de35..760852c73 100644 --- a/apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx +++ b/apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx @@ -50,7 +50,10 @@ let monacoInit: Promise | null = null; async function loadMonaco(): Promise { if (!monacoInit) { monacoInit = (async () => { - const EditorWorker = (await import("monaco-editor/esm/vs/editor/editor.worker?worker")).default; + const [{ default: EditorWorker }, { default: TsWorker }] = await Promise.all([ + import("monaco-editor/esm/vs/editor/editor.worker?worker"), + import("monaco-editor/esm/vs/language/typescript/ts.worker?worker"), + ]); const globalAny = globalThis as typeof globalThis & { MonacoEnvironment?: { getWorker?: (workerId: string, label: string) => Worker; @@ -59,7 +62,12 @@ async function loadMonaco(): Promise { const existing = globalAny.MonacoEnvironment; globalAny.MonacoEnvironment = { ...existing, - getWorker: existing?.getWorker ?? (() => new EditorWorker()) + getWorker: existing?.getWorker ?? ((_workerId: string, label: string) => { + if (label === "typescript" || label === "javascript") { + return new TsWorker(); + } + return new EditorWorker(); + }) }; return await import("monaco-editor"); })(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 3be487455..6dc11236c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -133,8 +133,6 @@ const UNIFIED_PERMISSION_OPTIONS: Array<{ value: AgentChatUnifiedPermissionMode; { value: "full-auto", label: "Full auto" }, ]; -// CURSOR_MODE_LABELS imported from shared/cursorModes.ts - function cursorModeLabel(modeId: string): string { const normalized = modeId.trim().toLowerCase(); if (!normalized.length) return "Agent"; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 3929ef98c..53757857e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { useLocation, useNavigate } from "react-router-dom"; @@ -637,29 +637,45 @@ function CollapsibleCard({ className?: string; }) { const [open, setOpen] = useState(defaultOpen); + // Track whether the user explicitly collapsed while forceOpen is active + const [userCollapsed, setUserCollapsed] = useState(false); const prevForceOpen = useRef(forceOpen); + const panelId = useId(); useEffect(() => { // Auto-collapse when forceOpen transitions from true → falsy (turn finished) if (prevForceOpen.current === true && !forceOpen) { setOpen(false); + setUserCollapsed(false); + } + // Reset user override when forceOpen activates (new turn) + if (!prevForceOpen.current && forceOpen) { + setUserCollapsed(false); } prevForceOpen.current = forceOpen; }, [forceOpen]); - const isOpen = forceOpen === true ? true : open; + const isOpen = forceOpen === true ? !userCollapsed : open; return (
- {isOpen ?
{children}
: null} + {isOpen ?
{children}
: null}
); } @@ -925,6 +941,25 @@ type AssistantPresentation = { glyph: React.ReactNode; }; +const KNOWN_PROVIDER_LABELS = new Set(["Claude", "Codex", "Cursor"]); +const GENERIC_ASSISTANT_LABELS = new Set(["Agent", "Assistant", ...KNOWN_PROVIDER_LABELS]); + +function inferProviderLabel(meta: { family: string | null; cliCommand: string | null }): string | null { + if (meta.family === "anthropic" || meta.cliCommand === "claude") return "Claude"; + if (meta.cliCommand === "codex") return "Codex"; + if (meta.family === "cursor" || meta.cliCommand === "cursor") return "Cursor"; + return null; +} + +function providerGlyph(provider: string | null): React.ReactNode { + switch (provider) { + case "Claude": return ; + case "Codex": return ; + case "Cursor": return ; + default: return ; + } +} + function resolveAssistantPresentation({ assistantLabel, turnModel, @@ -934,35 +969,14 @@ function resolveAssistantPresentation({ }): AssistantPresentation { const customLabel = assistantLabel?.trim() ?? ""; const modelMeta = turnModel ? resolveModelMeta(turnModel.modelId, turnModel.model) : { family: null, cliCommand: null }; - const providerLabel = - modelMeta.family === "anthropic" || modelMeta.cliCommand === "claude" - ? "Claude" - : modelMeta.cliCommand === "codex" - ? "Codex" - : modelMeta.family === "cursor" || modelMeta.cliCommand === "cursor" - ? "Cursor" - : null; - const fallbackProviderLabel = - customLabel === "Claude" || customLabel === "Codex" || customLabel === "Cursor" ? customLabel : null; + const resolvedProviderLabel = inferProviderLabel(modelMeta) + ?? (KNOWN_PROVIDER_LABELS.has(customLabel) ? customLabel : null); const hardOverrideLabel = - customLabel.length > 0 - && customLabel !== "Agent" - && customLabel !== "Assistant" - && customLabel !== "Claude" - && customLabel !== "Codex" - && customLabel !== "Cursor" + customLabel.length > 0 && !GENERIC_ASSISTANT_LABELS.has(customLabel) ? customLabel : null; - const resolvedProviderLabel = providerLabel ?? fallbackProviderLabel; const label = hardOverrideLabel ?? resolvedProviderLabel ?? "Assistant"; - const glyph = resolvedProviderLabel === "Claude" - ? - : resolvedProviderLabel === "Codex" - ? - : resolvedProviderLabel === "Cursor" - ? - : ; - return { label, glyph }; + return { label, glyph: providerGlyph(resolvedProviderLabel) }; } function commandTimelineVerb(status: Extract["status"]): string { @@ -1158,7 +1172,6 @@ function renderEvent( ) : null}
- {formatTime(envelope.timestamp)}
@@ -1476,7 +1489,6 @@ function renderEvent( Agent Question - {formatTime(envelope.timestamp)}
{event.question} @@ -2712,6 +2724,28 @@ export function AgentChatMessageList({ return () => cancelAnimationFrame(raf); }, [groupedRows, measurementTick, stickToBottom, showStreamingIndicator]); + // Observe scrollHeight changes via MutationObserver so streaming content + // (which grows existing rows without changing groupedRows identity) still + // triggers autoscroll. Scoped to the live-turn window: the observer is + // attached only while activeTurnId is non-null and tears down when the + // turn ends, avoiding unnecessary scroll-forcing after streaming finishes. + useEffect(() => { + if (!activeTurnId) return; + const el = scrollRef.current; + if (!el || typeof MutationObserver === "undefined") return; + let prevScrollHeight = el.scrollHeight; + const mo = new MutationObserver(() => { + if (el.scrollHeight !== prevScrollHeight) { + prevScrollHeight = el.scrollHeight; + if (stickToBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + } + }); + mo.observe(el, { childList: true, subtree: true, characterData: true }); + return () => mo.disconnect(); + }, [activeTurnId]); + // Observe the scroll container's size so we know the viewport height. useEffect(() => { const el = scrollRef.current; @@ -2739,11 +2773,17 @@ export function AgentChatMessageList({ }, []); /** Callback from MeasuredEventRow when it measures its real DOM height. */ + const measureFlushTimer = useRef | null>(null); + useEffect(() => () => { + if (measureFlushTimer.current) { + clearTimeout(measureFlushTimer.current); + measureFlushTimer.current = null; + } + }, []); const handleMeasure = useCallback((index: number, height: number) => { const prev = measuredHeights.current.get(index); if (prev !== height) { measuredHeights.current.set(index, height); - setMeasurementTick((value) => value + 1); const scrollEl = scrollRef.current; if (scrollEl && shouldVirtualize && !stickToBottomRef.current) { const adjustedScrollTop = reconcileMeasuredScrollTop({ @@ -2758,10 +2798,21 @@ export function AgentChatMessageList({ setScrollTop(adjustedScrollTop); } } + // Debounce measurement tick updates to batch rapid height changes + // into a single re-render instead of one per row. + if (!measureFlushTimer.current) { + measureFlushTimer.current = setTimeout(() => { + measureFlushTimer.current = null; + setMeasurementTick((value) => value + 1); + }, 80); + } } }, [rowHeight, shouldVirtualize]); // Compute the visible window of rows when virtualization is active. + // measurementTick forces recomputation when row heights are measured so + // totalHeight stays accurate — without this, scroll-to-top can break because + // the spacer heights are computed from stale estimates. const { startIndex, endIndex, totalHeight, offsetTop } = useMemo(() => { if (!shouldVirtualize) { return { startIndex: 0, endIndex: groupedRows.length, totalHeight: 0, offsetTop: 0 }; @@ -2773,7 +2824,8 @@ export function AgentChatMessageList({ containerHeight, rowHeight, }); - }, [shouldVirtualize, groupedRows.length, scrollTop, containerHeight, rowHeight]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldVirtualize, groupedRows.length, scrollTop, containerHeight, rowHeight, measurementTick]); const handleScroll = useCallback((event: React.UIEvent) => { const target = event.currentTarget; @@ -2858,14 +2910,16 @@ export function AgentChatMessageList({ }, [shouldVirtualize, endIndex, groupedRows.length, rowHeight]); const streamingIndicator = showStreamingIndicator ? ( - latestActivity ? ( - - ) : ( -
- - Working... -
- ) +
+ {latestActivity ? ( + + ) : ( +
+ + Working... +
+ )} +
) : null; const turnSummaryCard = turnSummary ? ( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index ad05abb15..c91c7bd4d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1427,7 +1427,6 @@ export function AgentChatPane({ const unsubscribe = window.ade.computerUse.onEvent((event) => { if (!selectedSessionId) return; if (event.owner?.kind === "chat_session" && event.owner.id === selectedSessionId) { - setProofDrawerOpen(true); void refreshComputerUseSnapshot(selectedSessionId, { force: true }); } }); @@ -1440,7 +1439,6 @@ export function AgentChatPane({ const usageEvent = event.usageEvent; const usageChatSessionId = usageEvent?.chatSessionId ?? usageEvent?.callerId ?? null; if (usageChatSessionId !== selectedSessionId) return; - setProofDrawerOpen(true); void refreshComputerUseSnapshot(selectedSessionId, { force: true }); }); return unsubscribe; diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index 7aa02f587..c4142efa8 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -446,6 +446,17 @@ export function appendCollapsedChatTranscriptEvent( && normalizedMessage !== "started" && normalizedMessage !== "completed"); if (!keepStatus) return; + + // Deduplicate consecutive identical status events (e.g. multiple "interrupted") + const previous = rows[rows.length - 1]; + if ( + previous?.event.type === "status" + && previous.event.turnStatus === event.turnStatus + && (previous.event.turnId ?? null) === (event.turnId ?? null) + && (previous.event.message ?? "") === (event.message ?? "") + ) { + return; + } } if (event.type === "system_notice" && event.noticeKind === "info" && event.message.trim().toLowerCase() === "session ready") { diff --git a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx index c829df10c..90e4bca34 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx @@ -31,6 +31,10 @@ vi.mock("monaco-editor/esm/vs/editor/editor.worker?worker", () => ({ default: class MockEditorWorker {}, })); +vi.mock("monaco-editor/esm/vs/language/typescript/ts.worker?worker", () => ({ + default: class MockTsWorker {}, +})); + vi.mock("monaco-editor", () => { const createModel = (value: string, language: string) => ({ value, diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index e272980d2..a74694ffc 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -142,7 +142,10 @@ let monacoInit: Promise | null = null; async function loadMonaco(): Promise { if (!monacoInit) { monacoInit = (async () => { - const EditorWorker = (await import("monaco-editor/esm/vs/editor/editor.worker?worker")).default; + const [{ default: EditorWorker }, { default: TsWorker }] = await Promise.all([ + import("monaco-editor/esm/vs/editor/editor.worker?worker"), + import("monaco-editor/esm/vs/language/typescript/ts.worker?worker"), + ]); const globalAny = globalThis as typeof globalThis & { MonacoEnvironment?: { getWorker?: (workerId: string, label: string) => Worker; @@ -151,7 +154,12 @@ async function loadMonaco(): Promise { const existing = globalAny.MonacoEnvironment; globalAny.MonacoEnvironment = { ...existing, - getWorker: existing?.getWorker ?? (() => new EditorWorker()) + getWorker: existing?.getWorker ?? ((_workerId: string, label: string) => { + if (label === "typescript" || label === "javascript") { + return new TsWorker(); + } + return new EditorWorker(); + }) }; return await import("monaco-editor"); })(); diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index c8583e11c..48fc2282f 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -45,7 +45,8 @@ export function CreateLaneDialog({ templates, selectedTemplateId, setSelectedTemplateId, - onNavigateToTemplates + onNavigateToTemplates, + importBranchWarning }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -71,6 +72,8 @@ export function CreateLaneDialog({ selectedTemplateId: string; setSelectedTemplateId: (id: string) => void; onNavigateToTemplates?: () => void; + /** Warning shown below the import branch selector (e.g. uncommitted changes). */ + importBranchWarning?: string | null; }) { const localBranches = createBranches.filter((b) => !b.isRemote); const allBranches = createBranches; @@ -184,6 +187,7 @@ export function CreateLaneDialog({ className={SELECT_CLASS_NAME + " !mt-0"} disabled={busy || laneCreated} aria-label="Import branch" + aria-describedby={importBranchWarning ? "import-branch-warning" : undefined} > {allBranches.map((b) => ( @@ -197,6 +201,17 @@ export function CreateLaneDialog({ Base will be auto-detected from git history
) : null} + {importBranchWarning ? ( + + ) : null} ) : (
diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index 1a5e983da..d91354aa1 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -227,6 +227,31 @@ describe("LaneGitActionsPane rescue action", () => { expect(rescueButton.getAttribute("title")).toMatch(/finish the current merge/i); }); + it("explains why sync is disabled when the lane is behind but still dirty", async () => { + mockStoreState = { + ...mockStoreState, + lanes: [ + buildLane({ + name: "PR-CONVERGENCE-CHILD", + status: { + dirty: true, + ahead: 0, + behind: 2, + remoteBehind: 0, + rebaseInProgress: false, + }, + }), + ], + }; + + renderPane(); + + const syncButton = await screen.findByRole("button", { name: "SYNC" }); + expect((syncButton as HTMLButtonElement).disabled).toBe(true); + expect(syncButton.getAttribute("title")).toMatch(/has uncommitted changes/i); + expect(syncButton.getAttribute("title")).toMatch(/before rebasing and pushing/i); + }); + it("treats auto-rebase conflicts as failures and links to the Rebase tab", async () => { const user = userEvent.setup(); const resolveRebaseConflict = vi.fn(); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 30af088a9..39a6d24d4 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -327,6 +327,37 @@ function SectionCard({ ); } +function HoverTitleButton({ + tooltip, + disabled, + style, + children, + ...buttonProps +}: React.ButtonHTMLAttributes & { + tooltip: string; +}) { + const button = ( + + ); + + if (!disabled) return button; + return ( + + {button} + + ); +} + function ActionButton({ title, detail, @@ -348,10 +379,11 @@ function ActionButton({ }) { const primary = emphasis === "primary"; return ( - + ); } @@ -1100,6 +1131,19 @@ export function LaneGitActionsPane({ const commitButtonLabel = getCommitButtonLabel({ busyAction, amendCommit }); const commitHelperText = getCommitHelperText({ commitMessage, commitMessageAi }); const primaryPushLabel = syncStatus?.hasUpstream === false ? "Publish lane" : "Push to remote"; + const syncButtonDisabled = !laneId || busyAction != null || lane?.status.behind === 0 || lane?.status.dirty; + const syncButtonTitle = useMemo(() => { + if (!laneId) return "Sync is unavailable until you select a child lane."; + if (busyAction) return `Sync is unavailable while '${busyAction}' is running.`; + if (!lane?.parentLaneId) return "Sync is only available for child lanes that track a parent lane."; + if (lane.status.dirty) { + return "Sync is unavailable because this lane has uncommitted changes. Commit, stash, or discard them before rebasing and pushing."; + } + if (lane.status.behind === 0) { + return `Sync is unavailable because ${lane.name} is already up to date with ${parentLane?.name ?? "its parent lane"}.`; + } + return `Rebase ${lane.name} onto ${parentLane?.name ?? "its parent lane"} and push the rewritten branch.`; + }, [busyAction, lane, laneId, parentLane]); const renderFileRow = (file: FileChange, mode: "staged" | "unstaged") => { const rowSelected = selectedPath === file.path && selectedMode === mode; @@ -1562,20 +1606,20 @@ export function LaneGitActionsPane({ {syncStatus?.hasUpstream === false ? "PUBLISH" : nextActionHint?.action === "force_push_lease" ? "FORCE PUSH" : "PUSH"} {lane?.parentLaneId ? ( - + ) : null} {/* Separator */} diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index e7483a3cd..4d42d39ec 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -38,6 +38,7 @@ import { } from "./laneUtils"; import { sessionStatusBucket } from "../../lib/terminalAttention"; import { isRunOwnedSession } from "../../lib/sessions"; +import { buildPrsRouteSearch } from "../prs/prsRouteState"; import type { ConflictChip, ConflictStatus, @@ -1036,15 +1037,27 @@ export function LanesPage() { const openRebaseDetails = useCallback((laneId?: string | null) => { const trimmedLaneId = typeof laneId === "string" ? laneId.trim() : ""; if (trimmedLaneId.length) { - const search = new URLSearchParams({ tab: "rebase", laneId: trimmedLaneId }); - navigate(`/prs?${search.toString()}`); + const search = buildPrsRouteSearch({ + activeTab: "rebase", + selectedPrId: null, + selectedQueueGroupId: null, + selectedRebaseItemId: trimmedLaneId, + }); + navigate(`/prs${search}`); return; } - navigate("/prs?tab=rebase"); + navigate("/prs?tab=workflows&workflow=rebase"); }, [navigate]); const openRebaseConflictResolver = useCallback((laneId: string, parentLaneId: string | null) => { - const search = new URLSearchParams({ tab: "rebase", laneId }); + const search = new URLSearchParams( + buildPrsRouteSearch({ + activeTab: "rebase", + selectedPrId: null, + selectedQueueGroupId: null, + selectedRebaseItemId: laneId, + }).slice(1), + ); if (parentLaneId) search.set("parentLaneId", parentLaneId); navigate(`/prs?${search.toString()}`); }, [navigate]); @@ -2019,6 +2032,12 @@ export function LanesPage() { selectedTemplateId={selectedTemplateId} setSelectedTemplateId={setSelectedTemplateId} onNavigateToTemplates={() => navigate("/settings?tab=lane-templates")} + importBranchWarning={ + createMode === "existing" && createImportBranch && primaryLane?.status.dirty + && createBranches.find((b) => b.name === createImportBranch && !b.isRemote)?.isCurrent + ? `This branch is currently checked out and has uncommitted changes. The new lane will only include committed changes\u2009—\u2009uncommitted work will not carry over.` + : null + } /> {/* Attach Lane dialog */} diff --git a/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx b/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx index b457f2d78..1b6b8c3ae 100644 --- a/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx +++ b/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx @@ -12,8 +12,10 @@ let monacoInit: Promise | null = null; async function loadMonaco(): Promise { if (!monacoInit) { monacoInit = (async () => { - // Configure the base editor worker (good enough for plain text + basic languages). - const EditorWorker = (await import("monaco-editor/esm/vs/editor/editor.worker?worker")).default; + const [{ default: EditorWorker }, { default: TsWorker }] = await Promise.all([ + import("monaco-editor/esm/vs/editor/editor.worker?worker"), + import("monaco-editor/esm/vs/language/typescript/ts.worker?worker"), + ]); const globalAny = globalThis as typeof globalThis & { MonacoEnvironment?: { getWorker?: (workerId: string, label: string) => Worker; @@ -22,7 +24,12 @@ async function loadMonaco(): Promise { const existing = globalAny.MonacoEnvironment; globalAny.MonacoEnvironment = { ...existing, - getWorker: existing?.getWorker ?? (() => new EditorWorker()) + getWorker: existing?.getWorker ?? ((_workerId: string, label: string) => { + if (label === "typescript" || label === "javascript") { + return new TsWorker(); + } + return new EditorWorker(); + }) }; return await import("monaco-editor"); diff --git a/apps/desktop/src/renderer/components/lanes/mergeSimulation/ConflictFileDiff.tsx b/apps/desktop/src/renderer/components/lanes/mergeSimulation/ConflictFileDiff.tsx index 016dd8c83..d3486c9a2 100644 --- a/apps/desktop/src/renderer/components/lanes/mergeSimulation/ConflictFileDiff.tsx +++ b/apps/desktop/src/renderer/components/lanes/mergeSimulation/ConflictFileDiff.tsx @@ -10,7 +10,10 @@ let monacoInit: Promise | null = null; async function loadMonaco(): Promise { if (!monacoInit) { monacoInit = (async () => { - const EditorWorker = (await import("monaco-editor/esm/vs/editor/editor.worker?worker")).default; + const [{ default: EditorWorker }, { default: TsWorker }] = await Promise.all([ + import("monaco-editor/esm/vs/editor/editor.worker?worker"), + import("monaco-editor/esm/vs/language/typescript/ts.worker?worker"), + ]); const globalAny = globalThis as typeof globalThis & { MonacoEnvironment?: { getWorker?: (workerId: string, label: string) => Worker; @@ -19,7 +22,12 @@ async function loadMonaco(): Promise { const existing = globalAny.MonacoEnvironment; globalAny.MonacoEnvironment = { ...existing, - getWorker: existing?.getWorker ?? (() => new EditorWorker()) + getWorker: existing?.getWorker ?? ((_workerId: string, label: string) => { + if (label === "typescript" || label === "javascript") { + return new TsWorker(); + } + return new EditorWorker(); + }) }; return await import("monaco-editor"); })(); diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index a32591248..2ce5b9630 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -11,6 +11,7 @@ import { WorkflowsTab, type WorkflowCategory } from "./tabs/WorkflowsTab"; import { SANS_FONT } from "../lanes/laneDesignTokens"; import { isMissionLaneHiddenByDefault } from "../lanes/laneUtils"; import { buildPrsRouteSearch, parsePrsRouteState } from "./prsRouteState"; +import { resolveRouteRebaseSelection } from "./shared/rebaseNeedUtils"; type SurfaceMode = "github" | "workflows"; @@ -28,6 +29,7 @@ function PRsPageInner() { setSelectedPrId, selectedQueueGroupId, setSelectedQueueGroupId, + rebaseNeeds, selectedRebaseItemId, setSelectedRebaseItemId, loading, @@ -66,7 +68,10 @@ function PRsPageInner() { }); const tab = routeState.tab; const workflowTab = routeState.workflowTab; - const laneId = routeState.laneId; + const routeRebaseItemId = resolveRouteRebaseSelection({ + rebaseNeeds, + routeItemId: routeState.laneId, + }); if (tab === "github" || tab === "normal") { setActiveTab("normal"); @@ -86,7 +91,7 @@ function PRsPageInner() { setSelectedQueueGroupId(routeState.queueGroupId ?? null); } if (tab === "rebase" || workflowTab === "rebase") { - setSelectedRebaseItemId(laneId ?? null); + setSelectedRebaseItemId(routeRebaseItemId); } } catch { // Ignore malformed URLs and fall back to current state. @@ -100,7 +105,7 @@ function PRsPageInner() { window.removeEventListener("popstate", syncFromLocation); window.removeEventListener("hashchange", syncFromLocation); }; - }, [location.search, setActiveTab, setSelectedPrId, setSelectedQueueGroupId, setSelectedRebaseItemId]); + }, [location.search, rebaseNeeds, setActiveTab, setSelectedPrId, setSelectedQueueGroupId, setSelectedRebaseItemId]); React.useEffect(() => { const nextSearch = buildPrsRouteSearch({ diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx index 35d39da3f..21c18dfa3 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx @@ -1,10 +1,24 @@ // @vitest-environment jsdom import React from "react"; +import { MemoryRouter } from "react-router-dom"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { LaneSummary, PrActivityEvent, PrCheck, PrReviewThread, PrStatus, PrWithConflicts } from "../../../../shared/types"; +import type { + GitUpstreamSyncStatus, + IssueInventoryItem, + LaneSummary, + PrActivityEvent, + PrAiResolutionEventPayload, + PrCheck, + PrConvergenceState, + PrConvergenceStatePatch, + PrReviewThread, + PrStatus, + PrWithConflicts, + TerminalSessionStatus, +} from "../../../../shared/types"; const mockUsePrs = vi.fn(); @@ -120,7 +134,7 @@ function makePr(): PrWithConflicts { }; } -function makeLane(): LaneSummary { +function makeLane(overrides: Partial = {}): LaneSummary { return { id: "lane-1", name: "feature/pr-80", @@ -142,6 +156,7 @@ function makeLane(): LaneSummary { folder: null, createdAt: "2026-03-23T10:00:00.000Z", archivedAt: null, + ...overrides, }; } @@ -158,15 +173,81 @@ function makeStatus(overrides: Partial = {}): PrStatus { }; } +function makeConvergenceState(overrides: Partial = {}): PrConvergenceState { + const now = "2026-03-23T12:30:00.000Z"; + return { + prId: "pr-80", + autoConvergeEnabled: false, + status: "idle", + pollerStatus: "idle", + currentRound: 0, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function makeInventoryItem(overrides: Partial = {}): IssueInventoryItem { + return { + id: "inv-1", + prId: "pr-80", + source: "human", + type: "review_thread", + externalId: "thread:1", + state: "new", + round: 0, + filePath: "src/prs.ts", + line: 18, + severity: "major", + headline: "Tighten review-thread handling", + body: "Please verify the thread before replying.", + author: "reviewer", + url: "https://example.com/thread/1", + dismissReason: null, + agentSessionId: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + ...overrides, + }; +} + function renderPane(args: { checks: PrCheck[]; + freshChecks?: PrCheck[]; reviewThreads: PrReviewThread[]; lanes?: LaneSummary[]; + laneStatusOverrides?: Partial; onNavigate?: (path: string) => void; activity?: PrActivityEvent[]; statusOverrides?: Partial; mergeMethod?: "merge" | "squash" | "rebase"; + convergenceState?: PrConvergenceState | null; + sessionStatus?: TerminalSessionStatus; + syncStatus?: Partial; + inventorySnapshot?: { + items: IssueInventoryItem[]; + convergence: { currentRound: number; maxRounds: number; totalNew: number; totalSentToAgent: number; isConverging: boolean }; + }; }) { + const laneList = args.lanes ?? [makeLane({ + status: { + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: -1, + rebaseInProgress: false, + ...args.laneStatusOverrides, + }, + })]; const issueResolutionStart = vi.fn().mockResolvedValue({ sessionId: "session-1", laneId: "lane-1", @@ -176,7 +257,58 @@ function renderPane(args: { title: "Resolve PR #80 issues", prompt: "Prepared issue resolver prompt", }); + const aiResolutionStop = vi.fn().mockResolvedValue(undefined); const getReviewThreads = vi.fn().mockResolvedValue(args.reviewThreads); + const getChecks = vi.fn().mockResolvedValue(args.freshChecks ?? args.checks); + const getStatus = vi.fn().mockResolvedValue(args.statusOverrides ? makeStatus(args.statusOverrides) : makeStatus()); + const issueInventorySync = vi.fn().mockResolvedValue({ + items: args.inventorySnapshot?.items ?? [], + convergence: args.inventorySnapshot?.convergence ?? { currentRound: 0, maxRounds: 5, totalNew: 0, totalSentToAgent: 0, isConverging: false }, + }); + const issueInventoryReset = vi.fn().mockResolvedValue(undefined); + const getSyncStatus = vi.fn().mockResolvedValue({ + hasUpstream: true, + upstreamRef: "origin/feature/pr-80", + ahead: 0, + behind: 0, + diverged: false, + recommendedAction: "none", + ...args.syncStatus, + } satisfies GitUpstreamSyncStatus); + const persistedSessionId = args.convergenceState?.activeSessionId ?? "session-1"; + const getSession = vi.fn().mockImplementation(async (lookupId?: string) => { + if (!args.sessionStatus) return null; + const resolvedId = lookupId ?? persistedSessionId; + return { + id: resolvedId, + laneId: "lane-1", + laneName: "feature/pr-80", + ptyId: null, + tracked: true, + pinned: false, + goal: null, + toolType: "codex-chat", + title: "Resolve PR #80 issues", + status: args.sessionStatus, + startedAt: "2026-03-23T12:00:00.000Z", + endedAt: args.sessionStatus === "running" ? null : "2026-03-23T12:30:00.000Z", + exitCode: args.sessionStatus === "completed" ? 0 : 1, + transcriptPath: "/tmp/transcript.log", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: args.sessionStatus === "running" ? "running" : "exited", + resumeCommand: null, + }; + }); + let aiResolutionListener: ((event: PrAiResolutionEventPayload) => void) | null = null; + const onAiResolutionEvent = vi.fn((callback: (event: PrAiResolutionEventPayload) => void) => { + aiResolutionListener = callback; + return () => { + if (aiResolutionListener === callback) aiResolutionListener = null; + }; + }); const writeClipboardText = vi.fn().mockResolvedValue(undefined); const land = vi.fn().mockResolvedValue({ prId: "pr-80", @@ -188,6 +320,31 @@ function renderPane(args: { error: null, }); const onRefresh = vi.fn().mockResolvedValue(undefined); + let currentConvergence: PrConvergenceState | null = args.convergenceState ?? null; + const loadConvergenceState = vi.fn().mockImplementation(async () => currentConvergence); + const saveConvergenceState = vi.fn().mockImplementation(async (_prId: string, patch: PrConvergenceStatePatch) => { + currentConvergence = currentConvergence + ? { ...currentConvergence, ...patch, updatedAt: new Date().toISOString() } + : makeConvergenceState(patch); + return currentConvergence; + }); + const resetConvergenceState = vi.fn().mockImplementation(async () => { + currentConvergence = null; + return undefined; + }); + mockUsePrs.mockReturnValue({ + convergenceStatesByPrId: currentConvergence ? { "pr-80": currentConvergence } : {}, + loadConvergenceState, + saveConvergenceState, + resetConvergenceState, + rebaseNeeds: [], + resolverModel: "openai/gpt-5.4-codex", + resolverReasoningLevel: "high", + resolverPermissionMode: "guarded_edit", + setResolverModel: vi.fn(), + setResolverReasoningLevel: vi.fn(), + setResolverPermissionMode: vi.fn(), + }); Object.assign(window, { ade: { prs: { @@ -206,38 +363,83 @@ function renderPane(args: { getActionRuns: vi.fn().mockResolvedValue([]), getActivity: vi.fn().mockResolvedValue(args.activity ?? []), getReviewThreads, + issueInventorySync, + issueInventoryReset, + pipelineSettingsGet: vi.fn().mockResolvedValue({ + autoMerge: false, + mergeMethod: "repo_default", + maxRounds: 5, + onRebaseNeeded: "pause", + }), + getChecks, + getStatus, + onAiResolutionEvent, issueResolutionStart, issueResolutionPreviewPrompt, + aiResolutionStop, + loadConvergenceState, + saveConvergenceState, + resetConvergenceState, land, openInGitHub: vi.fn().mockResolvedValue(undefined), }, + lanes: { + list: vi.fn().mockResolvedValue(laneList), + }, + git: { + getSyncStatus, + }, app: { openExternal: vi.fn(), writeClipboardText, }, + sessions: { + get: getSession, + }, + ai: { + getStatus: vi.fn().mockResolvedValue({ + availableModels: [], + }), + }, }, }); return { issueResolutionStart, issueResolutionPreviewPrompt, + aiResolutionStop, getReviewThreads, + issueInventorySync, + issueInventoryReset, + getChecks, + getStatus, + getSyncStatus, + getSession, + onAiResolutionEvent, + emitAiResolutionEvent: (event: PrAiResolutionEventPayload) => { + aiResolutionListener?.(event); + }, + loadConvergenceState, + saveConvergenceState, + resetConvergenceState, writeClipboardText, land, onRefresh, ...render( - , + + + , ), }; } @@ -245,6 +447,11 @@ function renderPane(args: { describe("PrDetailPane issue resolver CTA", () => { beforeEach(() => { mockUsePrs.mockReturnValue({ + convergenceStatesByPrId: {}, + loadConvergenceState: vi.fn(), + saveConvergenceState: vi.fn(), + resetConvergenceState: vi.fn(), + rebaseNeeds: [], resolverModel: "openai/gpt-5.4-codex", resolverReasoningLevel: "high", resolverPermissionMode: "guarded_edit", @@ -258,19 +465,16 @@ describe("PrDetailPane issue resolver CTA", () => { cleanup(); }); - it.each(visibilityCases)("$name", async ({ checks, reviewThreads, statusOverrides, visible }) => { + it.each(visibilityCases)("$name — Path to Merge tab is always visible", async ({ checks, reviewThreads, statusOverrides }) => { renderPane({ checks, reviewThreads, statusOverrides }); + // Path to Merge is now a permanent tab (2nd position), always rendered await waitFor(() => { - if (visible) { - expect(screen.getByRole("button", { name: /path to merge/i })).toBeTruthy(); - } else { - expect(screen.queryByRole("button", { name: /path to merge/i })).toBeNull(); - } + expect(screen.getByRole("button", { name: /path to merge/i })).toBeTruthy(); }); }); - it("shows the action in both the header and the checks tab when issues are actionable", async () => { + it("shows the resolve action in the checks tab when issues are actionable", async () => { const user = userEvent.setup(); renderPane({ checks: [makeCheck()], @@ -280,8 +484,7 @@ describe("PrDetailPane issue resolver CTA", () => { await user.click(screen.getByRole("button", { name: /ci \/ checks/i })); await waitFor(() => { - // "Path to Merge" in header + "Resolve issues with agent" in ChecksTab - expect(screen.getByRole("button", { name: /path to merge/i })).toBeTruthy(); + // "Resolve issues with agent" in ChecksTab expect(screen.getByRole("button", { name: /resolve issues with agent/i })).toBeTruthy(); }); }); @@ -337,7 +540,7 @@ describe("PrDetailPane issue resolver CTA", () => { it("launches the issue resolver chat and navigates to the work session", async () => { const user = userEvent.setup(); const onNavigate = vi.fn(); - const { issueResolutionStart } = renderPane({ + const { issueResolutionStart, saveConvergenceState } = renderPane({ checks: [makeCheck()], reviewThreads: [], onNavigate, @@ -353,10 +556,420 @@ describe("PrDetailPane issue resolver CTA", () => { scope: "checks", additionalInstructions: "extra context", })); + expect(saveConvergenceState).toHaveBeenCalledWith("pr-80", expect.objectContaining({ + autoConvergeEnabled: false, + status: "running", + pollerStatus: "idle", + activeSessionId: "session-1", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-1", + })); expect(onNavigate).toHaveBeenCalledWith("/work?laneId=lane-1&sessionId=session-1"); }); }); + it("subscribes to modal-launched resolver sessions and refreshes convergence state after cancellation", async () => { + const user = userEvent.setup(); + const { + emitAiResolutionEvent, + getReviewThreads, + issueInventorySync, + onAiResolutionEvent, + onRefresh, + } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + inventorySnapshot: { + items: [makeInventoryItem({ headline: "Fresh inventory after cancellation" })], + convergence: { currentRound: 0, maxRounds: 5, totalNew: 1, totalSentToAgent: 0, isConverging: false }, + }, + }); + + await user.click(screen.getByRole("button", { name: /ci \/ checks/i })); + await user.click(await screen.findByRole("button", { name: /resolve issues with agent/i })); + await user.click(screen.getByRole("button", { name: /launch resolver/i })); + + await waitFor(() => { + expect(onAiResolutionEvent).toHaveBeenCalledTimes(1); + }); + + onRefresh.mockClear(); + getReviewThreads.mockClear(); + issueInventorySync.mockClear(); + + await React.act(async () => { + emitAiResolutionEvent({ + sessionId: "session-1", + status: "cancelled", + message: "cancelled", + timestamp: "2026-03-23T12:31:00.000Z", + }); + }); + + await waitFor(() => { + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(getReviewThreads).toHaveBeenCalledTimes(1); + expect(issueInventorySync).toHaveBeenCalledTimes(1); + }); + }); + + it("keeps a Path to Merge manual launch in manual mode", async () => { + const user = userEvent.setup(); + const { issueResolutionStart, saveConvergenceState } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + inventorySnapshot: { + items: [makeInventoryItem()], + convergence: { currentRound: 0, maxRounds: 5, totalNew: 1, totalSentToAgent: 0, isConverging: false }, + }, + }); + + await user.click(screen.getByRole("button", { name: /path to merge/i })); + await user.click(await screen.findByRole("button", { name: /launch agent/i })); + + await waitFor(() => { + expect(issueResolutionStart).toHaveBeenCalledWith(expect.objectContaining({ + prId: "pr-80", + })); + expect(saveConvergenceState).toHaveBeenCalledWith("pr-80", expect.objectContaining({ + autoConvergeEnabled: false, + status: "running", + activeSessionId: "session-1", + })); + }); + + expect(saveConvergenceState).not.toHaveBeenCalledWith("pr-80", expect.objectContaining({ + autoConvergeEnabled: true, + })); + }); + + it("restores a persisted convergence session on mount and remounts with the exact session URL", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const convergenceState = makeConvergenceState({ + autoConvergeEnabled: true, + status: "running", + pollerStatus: "idle", + currentRound: 2, + activeSessionId: "session-2", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-2", + lastStartedAt: "2026-03-23T12:29:00.000Z", + }); + + const firstRender = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + onNavigate, + convergenceState, + }); + + await waitFor(() => { + expect(firstRender.loadConvergenceState).toHaveBeenCalledWith("pr-80", { force: true }); + }); + + await user.click(screen.getByRole("button", { name: /path to merge/i })); + + const [viewSessionButton] = await screen.findAllByRole("button", { name: /view session/i }); + await user.click(viewSessionButton); + + expect(onNavigate).toHaveBeenCalledWith("/work?laneId=lane-1&sessionId=session-2"); + firstRender.unmount(); + + const secondRender = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + onNavigate, + convergenceState, + }); + + await waitFor(() => { + expect(secondRender.loadConvergenceState).toHaveBeenCalledWith("pr-80", { force: true }); + }); + + await user.click(screen.getByRole("button", { name: /path to merge/i })); + + await screen.findAllByRole("button", { name: /view session/i }); + + secondRender.unmount(); + }); + + it("pauses a restored auto-converge run when the persisted session has already been disposed", async () => { + const convergenceState = makeConvergenceState({ + autoConvergeEnabled: true, + status: "running", + pollerStatus: "idle", + currentRound: 2, + activeSessionId: "session-2", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-2", + lastStartedAt: "2026-03-23T12:29:00.000Z", + }); + + const { saveConvergenceState } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + convergenceState, + sessionStatus: "disposed", + }); + + await waitFor(() => { + expect(saveConvergenceState).toHaveBeenCalledWith("pr-80", expect.objectContaining({ + status: "paused", + pollerStatus: "paused", + activeSessionId: null, + pauseReason: "Agent session stopped before completion.", + })); + }); + }); + + it("pauses auto-converge when a completed agent session leaves unpublished commits", async () => { + const convergenceState = makeConvergenceState({ + autoConvergeEnabled: true, + status: "running", + pollerStatus: "idle", + currentRound: 2, + activeSessionId: "session-1", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-1", + lastStartedAt: "2026-03-23T12:29:00.000Z", + }); + + const { saveConvergenceState, getSyncStatus } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + convergenceState, + sessionStatus: "completed", + syncStatus: { ahead: 1, recommendedAction: "push" }, + }); + + await waitFor(() => { + expect(getSyncStatus).toHaveBeenCalledWith({ laneId: "lane-1" }); + expect(saveConvergenceState).toHaveBeenCalledWith("pr-80", expect.objectContaining({ + status: "paused", + pollerStatus: "paused", + activeSessionId: null, + pauseReason: expect.stringContaining("not pushed to the PR branch"), + })); + }); + }); + + it("marks a manual convergence round as failed when the agent exits with unpushed commits", async () => { + const convergenceState = makeConvergenceState({ + autoConvergeEnabled: false, + status: "running", + pollerStatus: "idle", + currentRound: 1, + activeSessionId: "session-1", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-1", + lastStartedAt: "2026-03-23T12:29:00.000Z", + }); + + const { saveConvergenceState } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + convergenceState, + sessionStatus: "completed", + syncStatus: { ahead: 1, recommendedAction: "push" }, + }); + + await waitFor(() => { + expect(saveConvergenceState).toHaveBeenCalledWith("pr-80", expect.objectContaining({ + status: "failed", + pollerStatus: "stopped", + activeSessionId: null, + errorMessage: expect.stringContaining("not pushed to the PR branch"), + })); + }); + + expect(screen.getByText(/not pushed to the PR branch/i)).toBeTruthy(); + }); + + it("restores a persisted waiting-for-checks runtime from canonical state", async () => { + const convergenceState = makeConvergenceState({ + autoConvergeEnabled: true, + status: "polling", + pollerStatus: "waiting_for_checks", + currentRound: 2, + activeSessionId: null, + }); + + const { loadConvergenceState } = renderPane({ + checks: [makeCheck({ status: "in_progress", conclusion: null })], + reviewThreads: [], + convergenceState, + inventorySnapshot: { + items: [makeInventoryItem()], + convergence: { currentRound: 2, maxRounds: 5, totalNew: 1, totalSentToAgent: 0, isConverging: true }, + }, + }); + + await waitFor(() => { + expect(loadConvergenceState).toHaveBeenCalledWith("pr-80", { force: true }); + }); + }); + + it("refreshes stale convergence checks when opening the path to merge tab", async () => { + const user = userEvent.setup(); + renderPane({ + checks: [makeCheck({ status: "in_progress", conclusion: null })], + freshChecks: [makeCheck({ status: "completed", conclusion: "success" })], + reviewThreads: [], + inventorySnapshot: { + items: [makeInventoryItem()], + convergence: { currentRound: 0, maxRounds: 5, totalNew: 1, totalSentToAgent: 0, isConverging: false }, + }, + }); + + await user.click(screen.getByRole("button", { name: /path to merge/i })); + + await waitFor(() => { + expect((screen.getByRole("button", { name: /launch agent/i }) as HTMLButtonElement).disabled).toBe(false); + }); + }); + + it("disabling auto-converge during an active round stops the session and clears the handle", async () => { + const user = userEvent.setup(); + const convergenceState = makeConvergenceState({ + autoConvergeEnabled: true, + status: "running", + pollerStatus: "idle", + currentRound: 2, + activeSessionId: "session-2", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-2", + lastStartedAt: "2026-03-23T12:29:00.000Z", + }); + + const { aiResolutionStop, saveConvergenceState } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + convergenceState, + sessionStatus: "running", + }); + + await user.click(screen.getByRole("button", { name: /path to merge/i })); + await user.click(screen.getAllByRole("button", { name: /stop auto-converge/i })[0]!); + + await waitFor(() => { + expect(aiResolutionStop).toHaveBeenCalledWith({ sessionId: "session-2" }); + expect(saveConvergenceState).toHaveBeenCalledWith("pr-80", expect.objectContaining({ + autoConvergeEnabled: false, + status: "stopped", + activeSessionId: null, + activeHref: null, + })); + }); + }); + + it("retains session handle when stop fails during auto-converge toggle off", async () => { + const user = userEvent.setup(); + const convergenceState = makeConvergenceState({ + autoConvergeEnabled: true, + status: "running", + pollerStatus: "idle", + currentRound: 2, + activeSessionId: "session-2", + activeLaneId: "lane-1", + activeHref: "/work?laneId=lane-1&sessionId=session-2", + lastStartedAt: "2026-03-23T12:29:00.000Z", + }); + + const { aiResolutionStop, saveConvergenceState } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + convergenceState, + sessionStatus: "running", + }); + + aiResolutionStop.mockRejectedValueOnce(new Error("Network timeout")); + + await user.click(screen.getByRole("button", { name: /path to merge/i })); + await user.click(screen.getAllByRole("button", { name: /stop auto-converge/i })[0]!); + + await waitFor(() => { + expect(aiResolutionStop).toHaveBeenCalledWith({ sessionId: "session-2" }); + expect(saveConvergenceState).toHaveBeenCalledWith("pr-80", expect.objectContaining({ + autoConvergeEnabled: false, + status: "running", + activeSessionId: "session-2", + activeHref: "/work?laneId=lane-1&sessionId=session-2", + errorMessage: "Network timeout", + })); + }); + }); + + it("refreshes convergence inventory from the header refresh button without leaving the tab", async () => { + const user = userEvent.setup(); + const { + getReviewThreads, + issueInventorySync, + onRefresh, + } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + inventorySnapshot: { + items: [makeInventoryItem({ headline: "Initial review inventory" })], + convergence: { currentRound: 0, maxRounds: 5, totalNew: 1, totalSentToAgent: 0, isConverging: false }, + }, + }); + + await user.click(screen.getByRole("button", { name: /path to merge/i })); + expect(await screen.findByText("Initial review inventory")).toBeTruthy(); + + issueInventorySync.mockResolvedValueOnce({ + items: [makeInventoryItem({ id: "inv-2", externalId: "thread:2", headline: "Refreshed review inventory" })], + convergence: { currentRound: 0, maxRounds: 5, totalNew: 1, totalSentToAgent: 0, isConverging: false }, + }); + onRefresh.mockClear(); + getReviewThreads.mockClear(); + issueInventorySync.mockClear(); + + await user.click(screen.getByRole("button", { name: /^refresh$/i })); + + expect(await screen.findByText("Refreshed review inventory")).toBeTruthy(); + await waitFor(() => { + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(getReviewThreads).toHaveBeenCalledTimes(1); + expect(issueInventorySync).toHaveBeenCalledTimes(1); + }); + }); + + it("rebuilds convergence inventory immediately after reset instead of leaving the panel blank", async () => { + const user = userEvent.setup(); + const { + issueInventoryReset, + issueInventorySync, + resetConvergenceState, + } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + inventorySnapshot: { + items: [makeInventoryItem({ headline: "Stale review inventory" })], + convergence: { currentRound: 2, maxRounds: 5, totalNew: 1, totalSentToAgent: 0, isConverging: true }, + }, + }); + + await user.click(screen.getByRole("button", { name: /path to merge/i })); + expect(await screen.findByText("Stale review inventory")).toBeTruthy(); + + issueInventorySync.mockResolvedValueOnce({ + items: [makeInventoryItem({ id: "inv-3", externalId: "thread:3", headline: "Rebuilt review inventory" })], + convergence: { currentRound: 0, maxRounds: 5, totalNew: 1, totalSentToAgent: 0, isConverging: false }, + }); + issueInventorySync.mockClear(); + + await user.click(screen.getByRole("button", { name: /reset inventory/i })); + + expect(await screen.findByText("Rebuilt review inventory")).toBeTruthy(); + await waitFor(() => { + expect(issueInventoryReset).toHaveBeenCalledWith("pr-80"); + expect(resetConvergenceState).toHaveBeenCalledWith("pr-80"); + expect(issueInventorySync).toHaveBeenCalledTimes(1); + }); + }); + it("reloads review threads when opening the resolver", async () => { const user = userEvent.setup(); const { getReviewThreads } = renderPane({ diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index 191c1d994..3d3415f7e 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -15,6 +15,8 @@ import type { LaneSummary, MergeMethod, LandResult, IssueInventorySnapshot, PipelineSettings, + PrConvergenceState, + PrConvergenceStatePatch, } from "../../../../shared/types"; import { DEFAULT_PIPELINE_SETTINGS } from "../../../../shared/types"; import { getPrIssueResolutionAvailability } from "../../../../shared/prIssueResolution"; @@ -22,7 +24,7 @@ import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, cardStyle, inlineBadge, outl import { getPrChecksBadge, getPrReviewsBadge, getPrStateBadge, InlinePrBadge, PrCiRunningIndicator } from "../shared/prVisuals"; import { PrIssueResolverModal } from "../shared/PrIssueResolverModal"; import { PrConvergencePanel } from "../shared/PrConvergencePanel"; -import type { IssueInventoryItem as PanelIssueItem, ConvergenceStatus as PanelConvergence } from "../shared/PrConvergencePanel"; +import type { IssueInventoryItem as PanelIssueItem, ConvergenceStatus as PanelConvergence, AutoConvergeWaitState } from "../shared/PrConvergencePanel"; import { PrLaneCleanupBanner } from "../shared/PrLaneCleanupBanner"; import { formatTimeAgo, formatTimestampFull } from "../shared/prFormatters"; import { describePrTargetDiff } from "../shared/laneBranchTargets"; @@ -30,7 +32,7 @@ import { findMatchingRebaseNeed, rebaseNeedItemKey } from "../shared/rebaseNeedU import { usePrs } from "../state/PrsContext"; // ---- Sub-tab type ---- -type DetailTab = "overview" | "files" | "checks" | "activity"; +type DetailTab = "overview" | "convergence" | "files" | "checks" | "activity"; // ---- Avatar component ---- function Avatar({ user, size = 20 }: { user: { login: string; avatarUrl?: string | null }; size?: number }) { @@ -401,6 +403,10 @@ export function PrDetailPane({ onOpenQueueView, }: PrDetailPaneProps) { const { + convergenceStatesByPrId, + loadConvergenceState, + saveConvergenceState, + resetConvergenceState, rebaseNeeds, resolverModel, resolverReasoningLevel, @@ -424,21 +430,105 @@ export function PrDetailPane({ const [issueResolverError, setIssueResolverError] = React.useState(null); // Convergence panel state - const [showConvergencePanel, setShowConvergencePanel] = React.useState(false); const [inventorySnapshot, setInventorySnapshot] = React.useState(null); + const [convergenceChecks, setConvergenceChecks] = React.useState(checks); const [convergenceBusy, setConvergenceBusy] = React.useState(false); const [autoConverge, setAutoConverge] = React.useState(false); const [convergenceSessionId, setConvergenceSessionId] = React.useState(null); - const [convergenceMerged, setConvergenceMerged] = React.useState(false); - const [convergencePauseReason, setConvergencePauseReason] = React.useState(null); + const [, setConvergenceMerged] = React.useState(false); + const [, setConvergencePauseReason] = React.useState(null); + const [convergenceSessionHref, setConvergenceSessionHref] = React.useState(null); const autoConvergeTimerRef = React.useRef | null>(null); + const convergenceSessionPollerRef = React.useRef(null); + const convergenceLoadSeqRef = React.useRef(0); + const convergenceTabLoadSeqRef = React.useRef(0); + const cachedConvergenceRuntimeRef = React.useRef(null); const behindCountRef = React.useRef(0); + const [autoConvergeWaitState, setAutoConvergeWaitState] = React.useState({ phase: "idle" }); const [pipelineSettings, setPipelineSettings] = React.useState(DEFAULT_PIPELINE_SETTINGS); const pipelineSettingsRef = React.useRef(DEFAULT_PIPELINE_SETTINGS); const mergeMethodRef = React.useRef(mergeMethod); mergeMethodRef.current = mergeMethod; const onRefreshRef = React.useRef(onRefresh); onRefreshRef.current = onRefresh; + cachedConvergenceRuntimeRef.current = convergenceStatesByPrId[pr.id] ?? null; + + React.useEffect(() => { + setConvergenceChecks(checks); + }, [checks, pr.id]); + + const buildSessionHref = React.useCallback((laneId: string, sessionId: string) => { + const lane = encodeURIComponent(laneId); + const session = encodeURIComponent(sessionId); + return `/work?laneId=${lane}&sessionId=${session}`; + }, []); + + const deriveWaitStateFromRuntime = React.useCallback((runtime: PrConvergenceState): AutoConvergeWaitState => { + if (runtime.status === "merged") return { phase: "merged" }; + if (runtime.status === "converged") return { phase: "complete" }; + if (runtime.status === "paused") { + return { phase: "paused", reason: runtime.pauseReason ?? "Auto-converge paused" }; + } + if (runtime.status === "stopped") { + return { phase: "idle" }; + } + if (runtime.status === "failed" || runtime.status === "cancelled") { + return { + phase: "paused", + reason: runtime.errorMessage ?? runtime.pauseReason ?? `Auto-converge ${runtime.status}`, + }; + } + if (runtime.activeSessionId) { + return { phase: "agent_running", sessionId: runtime.activeSessionId }; + } + if (runtime.pollerStatus === "waiting_for_checks") { + return { phase: "waiting_checks", pendingCount: 0, totalCount: 0 }; + } + if (runtime.pollerStatus === "waiting_for_comments") { + return { phase: "waiting_comments", stablePollCount: 0 }; + } + if (runtime.autoConvergeEnabled && (runtime.status === "launching" || runtime.status === "running" || runtime.status === "polling")) { + return { phase: "waiting_checks", pendingCount: 0, totalCount: 0 }; + } + return { phase: "idle" }; + }, []); + + const applyConvergenceRuntime = React.useCallback((runtime: PrConvergenceState | null) => { + if (!runtime) { + setConvergenceBusy(false); + setAutoConverge(false); + setConvergenceSessionId(null); + setConvergenceSessionHref(null); + setConvergenceMerged(false); + setConvergencePauseReason(null); + setAutoConvergeWaitState({ phase: "idle" }); + return; + } + + const nextHref = runtime.activeHref ?? ( + runtime.activeLaneId && runtime.activeSessionId + ? buildSessionHref(runtime.activeLaneId, runtime.activeSessionId) + : null + ); + + setAutoConverge(runtime.autoConvergeEnabled); + setConvergenceBusy(Boolean(runtime.activeSessionId) || runtime.status === "launching" || runtime.status === "running" || runtime.status === "polling"); + setConvergenceSessionId(runtime.activeSessionId); + setConvergenceSessionHref(nextHref); + setConvergenceMerged(runtime.status === "merged"); + setConvergencePauseReason(runtime.pauseReason); + setAutoConvergeWaitState(deriveWaitStateFromRuntime(runtime)); + }, [buildSessionHref, deriveWaitStateFromRuntime]); + + const saveConvergenceRuntime = React.useCallback((partial: PrConvergenceStatePatch) => { + void saveConvergenceState(pr.id, partial).catch((error: unknown) => { + console.error("pr_detail.save_convergence_runtime_failed", { + prId: pr.id, + state: partial, + error, + }); + }); + }, [pr.id, saveConvergenceState]); // Action states const [actionBusy, setActionBusy] = React.useState(false); @@ -459,6 +549,7 @@ export function PrDetailPane({ // expandedRun state removed — the unified ChecksTab manages its own expand state const [expandedFile, setExpandedFile] = React.useState(null); const detailLoadSeqRef = React.useRef(0); + const inventoryLoadSeqRef = React.useRef(0); const loadDetail = React.useCallback(async () => { const requestId = ++detailLoadSeqRef.current; @@ -490,13 +581,8 @@ export function PrDetailPane({ setIssueResolverCopyBusy(false); setIssueResolverCopyNotice(null); setShowIssueResolverModal(false); - setShowConvergencePanel(false); setInventorySnapshot(null); setConvergenceBusy(false); - setAutoConverge(false); - setConvergenceSessionId(null); - setConvergenceMerged(false); - setConvergencePauseReason(null); setPipelineSettings(DEFAULT_PIPELINE_SETTINGS); pipelineSettingsRef.current = DEFAULT_PIPELINE_SETTINGS; if (autoConvergeTimerRef.current) { @@ -517,11 +603,28 @@ export function PrDetailPane({ setShowReviewerEditor(false); setShowReviewModal(false); + const requestId = ++convergenceLoadSeqRef.current; + const cachedRuntime = cachedConvergenceRuntimeRef.current; + applyConvergenceRuntime(cachedRuntime); + void loadConvergenceState(pr.id, { force: true }) + .then((runtime) => { + if (requestId !== convergenceLoadSeqRef.current) return; + applyConvergenceRuntime(runtime); + }) + .catch(() => { + if (requestId !== convergenceLoadSeqRef.current) return; + if (!cachedRuntime) { + applyConvergenceRuntime(null); + } + }); + void loadDetail(); return () => { detailLoadSeqRef.current += 1; + inventoryLoadSeqRef.current += 1; + convergenceLoadSeqRef.current += 1; }; - }, [loadDetail, pr.id]); + }, [applyConvergenceRuntime, loadConvergenceState, loadDetail, pr.id]); React.useEffect(() => { if (!issueResolverCopyNotice) return; @@ -671,13 +774,26 @@ export function PrDetailPane({ additionalInstructions: args.additionalInstructions, }); setShowIssueResolverModal(false); + setConvergenceSessionId(result.sessionId); + setConvergenceSessionHref(result.href); + saveConvergenceRuntime({ + autoConvergeEnabled: autoConverge, + status: "running", + pollerStatus: "idle", + activeSessionId: result.sessionId, + activeLaneId: pr.laneId, + activeHref: result.href, + pauseReason: null, + errorMessage: null, + lastStartedAt: new Date().toISOString(), + }); onNavigate(result.href); } catch (err: unknown) { setIssueResolverError(err instanceof Error ? err.message : String(err)); } finally { setIssueResolverBusy(false); } - }, [onNavigate, pr.id, resolverModel, resolverPermissionMode, resolverReasoningLevel]); + }, [autoConverge, onNavigate, pr.id, pr.laneId, resolverModel, resolverPermissionMode, resolverReasoningLevel, saveConvergenceRuntime]); const handleCopyIssueResolverPrompt = React.useCallback(async ( args: { scope: "checks" | "comments" | "both"; additionalInstructions: string }, @@ -714,14 +830,36 @@ export function PrDetailPane({ // --------------------------------------------------------------------------- const syncInventory = React.useCallback(async () => { + const requestId = ++inventoryLoadSeqRef.current; try { - const snapshot = await window.ade.prs.issueInventorySync(pr.id); + const [snapshot, freshChecks] = await Promise.all([ + window.ade.prs.issueInventorySync(pr.id), + window.ade.prs.getChecks(pr.id).catch(() => checks), + ]); + if (requestId !== inventoryLoadSeqRef.current) return null; setInventorySnapshot(snapshot); + setConvergenceChecks(freshChecks); return snapshot; } catch { return null; } - }, [pr.id]); + }, [checks, pr.id]); + + const refreshDetailSurface = React.useCallback(async (options: { includeInventory?: boolean } = {}) => { + const tasks: Array> = [onRefresh(), loadDetail()]; + if (options.includeInventory) { + tasks.push(syncInventory()); + } + await Promise.all(tasks); + }, [loadDetail, onRefresh, syncInventory]); + + const handleRefresh = React.useCallback(async () => { + try { + await refreshDetailSurface({ includeInventory: activeTab === "convergence" }); + } catch (err: unknown) { + setActionError(err instanceof Error ? err.message : String(err)); + } + }, [activeTab, refreshDetailSurface]); const mapInventoryItems = React.useCallback((snapshot: IssueInventorySnapshot | null): PanelIssueItem[] => { if (!snapshot) return []; @@ -755,22 +893,30 @@ export function PrDetailPane({ return { state, currentRound: displayRound, maxRounds: c.maxRounds }; }, []); - // Sync inventory and load pipeline settings on panel open + // Sync inventory and load pipeline settings on convergence tab open React.useEffect(() => { - if (showConvergencePanel) { + if (activeTab === "convergence") { + const runId = ++convergenceTabLoadSeqRef.current; + const capturedPrId = pr.id; + void loadConvergenceState(capturedPrId, { force: true }).then((runtime) => { + if (runId !== convergenceTabLoadSeqRef.current) return; // stale + applyConvergenceRuntime(runtime); + }).catch(() => undefined); void syncInventory(); - void window.ade.prs.pipelineSettingsGet(pr.id).then((s) => { + void window.ade.prs.pipelineSettingsGet(capturedPrId).then((s) => { + if (runId !== convergenceTabLoadSeqRef.current) return; // stale setPipelineSettings(s); pipelineSettingsRef.current = s; - }); + }).catch(() => undefined); } - }, [showConvergencePanel, syncInventory, pr.id]); + }, [activeTab, applyConvergenceRuntime, loadConvergenceState, syncInventory, pr.id]); // Auto-converge: hybrid polling (checks complete + comment stabilization) // After agent session completes, polls every 60s. Triggers next round when: // 1. All GitHub checks are done (no queued/in_progress), AND // 2. Comment/thread count hasn't changed for 2 consecutive polls (~2 min stability) const autoConvergePollerRef = React.useRef | null>(null); + const startAutoConvergePollerRef = React.useRef<() => void>(() => undefined); const lastCommentCountRef = React.useRef(-1); const stableCountRef = React.useRef(0); const autoConvergeAdditionalRef = React.useRef(""); @@ -782,6 +928,8 @@ export function PrDetailPane({ autoConvergeRef.current = autoConverge; const convergenceSessionIdRef = React.useRef(convergenceSessionId); convergenceSessionIdRef.current = convergenceSessionId; + const convergenceSessionHrefRef = React.useRef(convergenceSessionHref); + convergenceSessionHrefRef.current = convergenceSessionHref; const stopAutoConvergePoller = React.useCallback(() => { if (autoConvergePollerRef.current) { @@ -793,10 +941,172 @@ export function PrDetailPane({ behindCountRef.current = 0; }, []); + const stopConvergenceSessionPoller = React.useCallback(() => { + if (convergenceSessionPollerRef.current) { + clearTimeout(convergenceSessionPollerRef.current); + convergenceSessionPollerRef.current = null; + } + }, []); + + const getConvergencePublishBlocker = React.useCallback(async (sessionId: string): Promise => { + const sessionDetailPromise = typeof window.ade?.sessions?.get === "function" + ? window.ade.sessions.get(sessionId).catch(() => null) + : Promise.resolve(null); + const syncStatusPromise = typeof window.ade?.git?.getSyncStatus === "function" + ? window.ade.git.getSyncStatus({ laneId: pr.laneId }).catch(() => null) + : Promise.resolve(null); + const laneListPromise = typeof window.ade?.lanes?.list === "function" + ? window.ade.lanes.list({ includeStatus: true }).catch(() => lanes) + : Promise.resolve(lanes); + const [sessionDetail, syncStatus, freshLanes] = await Promise.all([ + sessionDetailPromise, + syncStatusPromise, + laneListPromise, + ]); + const lane = freshLanes.find((entry) => entry.id === pr.laneId) ?? lanes.find((entry) => entry.id === pr.laneId) ?? null; + const hasDirtyChanges = Boolean(lane?.status.dirty); + const sessionHeadChanged = Boolean(sessionDetail?.headShaStart) + && Boolean(sessionDetail?.headShaEnd) + && sessionDetail?.headShaStart !== sessionDetail?.headShaEnd; + const hasUnpublishedCommits = syncStatus + ? syncStatus.ahead > 0 + || syncStatus.recommendedAction === "force_push_lease" + || ( + !syncStatus.hasUpstream + && sessionHeadChanged + ) + : false; + + if (!hasDirtyChanges && !hasUnpublishedCommits) return null; + + const pendingStates: string[] = []; + if (hasDirtyChanges) pendingStates.push("uncommitted changes"); + if (hasUnpublishedCommits) { + pendingStates.push( + syncStatus?.recommendedAction === "force_push_lease" + ? "commits that still need a force push" + : "commits that are not pushed to the PR branch", + ); + } + return `Agent session exited, but the lane still has ${pendingStates.join(" and ")}. Commit and push the lane before continuing.`; + }, [lanes, pr.laneId]); + + const handleConvergenceSessionTerminal = React.useCallback(async ( + args: { sessionId: string; status: "completed" | "failed" | "cancelled" | "disposed"; message?: string | null }, + ) => { + if (convergenceSessionIdRef.current !== args.sessionId) return; + + const now = new Date().toISOString(); + const activeHref = convergenceSessionHrefRef.current; + const failureReason = (() => { + const message = args.message?.trim(); + if (message) return message; + if (args.status === "cancelled") return "Agent session was cancelled."; + if (args.status === "disposed") return "Agent session stopped before completion."; + if (args.status === "failed") return "Agent session failed before completion."; + return null; + })(); + + setConvergenceBusy(false); + setConvergenceSessionId(null); + stopConvergenceSessionPoller(); + + await refreshDetailSurface({ includeInventory: true }).catch(() => {}); + + if (args.status === "completed") { + const publishBlocker = await getConvergencePublishBlocker(args.sessionId).catch(() => null); + if (publishBlocker) { + if (autoConvergeRef.current) { + stopAutoConvergePoller(); + setConvergencePauseReason(publishBlocker); + setAutoConvergeWaitState({ phase: "paused", reason: publishBlocker }); + saveConvergenceRuntime({ + status: "paused", + pollerStatus: "paused", + activeSessionId: null, + activeHref, + pauseReason: publishBlocker, + errorMessage: publishBlocker, + lastPausedAt: now, + lastStoppedAt: now, + }); + } else { + setActionError(publishBlocker); + saveConvergenceRuntime({ + status: "failed", + pollerStatus: "stopped", + activeSessionId: null, + activeHref, + pauseReason: null, + errorMessage: publishBlocker, + lastStoppedAt: now, + }); + setAutoConvergeWaitState({ phase: "idle" }); + } + return; + } + + if (autoConvergeRef.current) { + saveConvergenceRuntime({ + status: "polling", + pollerStatus: "waiting_for_checks", + activeSessionId: null, + activeHref, + pauseReason: null, + errorMessage: null, + lastPolledAt: now, + }); + setAutoConvergeWaitState({ phase: "waiting_checks", pendingCount: 0, totalCount: 0 }); + startAutoConvergePollerRef.current(); + } else { + saveConvergenceRuntime({ + status: "idle", + pollerStatus: "idle", + activeSessionId: null, + activeHref, + pauseReason: null, + errorMessage: null, + lastStoppedAt: now, + }); + setAutoConvergeWaitState({ phase: "idle" }); + } + return; + } + + if (autoConvergeRef.current) { + const reason = failureReason ?? "Agent session ended unexpectedly."; + stopAutoConvergePoller(); + setConvergencePauseReason(reason); + setAutoConvergeWaitState({ phase: "paused", reason }); + saveConvergenceRuntime({ + status: "paused", + pollerStatus: "paused", + activeSessionId: null, + activeHref, + pauseReason: reason, + errorMessage: reason, + lastPausedAt: now, + lastStoppedAt: now, + }); + return; + } + + saveConvergenceRuntime({ + status: args.status === "cancelled" ? "cancelled" : "failed", + pollerStatus: "stopped", + activeSessionId: null, + activeHref, + pauseReason: null, + errorMessage: failureReason, + lastStoppedAt: now, + }); + setAutoConvergeWaitState({ phase: "idle" }); + }, [getConvergencePublishBlocker, refreshDetailSurface, saveConvergenceRuntime, stopAutoConvergePoller, stopConvergenceSessionPoller]); + const startAutoConvergePoller = React.useCallback(() => { stopAutoConvergePoller(); - const scheduleTick = () => { + const scheduleTick = (delayMs = 60_000) => { autoConvergePollerRef.current = setTimeout(async () => { if (!autoConvergeRef.current) { stopAutoConvergePoller(); return; } try { @@ -806,6 +1116,7 @@ export function PrDetailPane({ window.ade.prs.issueInventorySync(pr.id), ]); setInventorySnapshot(snapshot); + setConvergenceChecks(freshChecks); // Skip rebase logic while an agent session is still active if (!convergenceSessionIdRef.current) { @@ -818,6 +1129,16 @@ export function PrDetailPane({ if (rebasePolicy === "pause") { stopAutoConvergePoller(); setConvergencePauseReason("PR is behind base branch. Rebase needed to continue."); + setAutoConvergeWaitState({ phase: "paused", reason: "PR is behind base branch. Rebase needed to continue." }); + saveConvergenceRuntime({ + status: "paused", + pollerStatus: "paused", + activeSessionId: null, + activeHref: convergenceSessionHref, + pauseReason: "PR is behind base branch. Rebase needed to continue.", + errorMessage: null, + lastPausedAt: new Date().toISOString(), + }); return; } // rebasePolicy === "auto_rebase" @@ -828,6 +1149,16 @@ export function PrDetailPane({ if (behindCountRef.current >= 3) { stopAutoConvergePoller(); setConvergencePauseReason("PR needs rebase but auto-rebase appears stuck. Resolve conflicts manually."); + setAutoConvergeWaitState({ phase: "paused", reason: "PR needs rebase but auto-rebase appears stuck. Resolve conflicts manually." }); + saveConvergenceRuntime({ + status: "paused", + pollerStatus: "paused", + activeSessionId: null, + activeHref: convergenceSessionHref, + pauseReason: "PR needs rebase but auto-rebase appears stuck. Resolve conflicts manually.", + errorMessage: null, + lastPausedAt: new Date().toISOString(), + }); return; } scheduleTick(); // Keep polling, give auto-rebase time to work @@ -841,6 +1172,18 @@ export function PrDetailPane({ (c: PrCheck) => c.status === "queued" || c.status === "in_progress", ); if (checksStillRunning) { + const pendingCount = freshChecks.filter((c: PrCheck) => c.status === "queued" || c.status === "in_progress").length; + setAutoConvergeWaitState({ phase: "waiting_checks", pendingCount, totalCount: freshChecks.length }); + saveConvergenceRuntime({ + status: "polling", + pollerStatus: "waiting_for_checks", + currentRound: snapshot.convergence.currentRound, + activeSessionId: null, + activeHref: convergenceSessionHref, + pauseReason: null, + errorMessage: null, + lastPolledAt: new Date().toISOString(), + }); lastCommentCountRef.current = -1; stableCountRef.current = 0; scheduleTick(); // Keep polling @@ -857,18 +1200,55 @@ export function PrDetailPane({ lastCommentCountRef.current = currentCount; // Trigger next round: checks done + 2 consecutive stable polls + has new items + if (stableCountRef.current < 2) { + setAutoConvergeWaitState({ phase: "waiting_comments", stablePollCount: stableCountRef.current }); + saveConvergenceRuntime({ + status: "polling", + pollerStatus: "waiting_for_comments", + currentRound: snapshot.convergence.currentRound, + activeSessionId: null, + activeHref: convergenceSessionHref, + pauseReason: null, + errorMessage: null, + lastPolledAt: new Date().toISOString(), + }); + } if (stableCountRef.current >= 2 && currentCount > 0) { stopAutoConvergePoller(); + setAutoConvergeWaitState({ phase: "ready" }); const convergence = snapshot.convergence; if (convergence.currentRound >= convergence.maxRounds) { - setAutoConverge(false); - return; // Max rounds reached + const reason = "Maximum auto-converge rounds reached."; + setConvergencePauseReason(reason); + setAutoConvergeWaitState({ phase: "paused", reason }); + saveConvergenceRuntime({ + status: "paused", + pollerStatus: "paused", + currentRound: snapshot.convergence.currentRound, + activeSessionId: null, + activeHref: convergenceSessionHrefRef.current, + pauseReason: reason, + errorMessage: null, + lastPausedAt: new Date().toISOString(), + }); + return; } // Launch next round void handleRunNextRoundRef.current?.(autoConvergeAdditionalRef.current); } else if (stableCountRef.current >= 2 && currentCount === 0) { // No new items after stabilization — convergence is done stopAutoConvergePoller(); + setAutoConvergeWaitState({ phase: "complete" }); + saveConvergenceRuntime({ + status: "converged", + pollerStatus: "idle", + currentRound: snapshot.convergence.currentRound, + activeSessionId: null, + activeHref: convergenceSessionHref, + pauseReason: null, + errorMessage: null, + lastStoppedAt: new Date().toISOString(), + }); // Auto-merge if enabled const settings = pipelineSettingsRef.current; @@ -889,23 +1269,60 @@ export function PrDetailPane({ : settings.mergeMethod; const res = await window.ade.prs.land({ prId: pr.id, method }); if (res.success) { + setAutoConvergeWaitState({ phase: "merged" }); setConvergenceMerged(true); setAutoConverge(false); + saveConvergenceRuntime({ + status: "merged", + pollerStatus: "idle", + activeSessionId: null, + activeHref: convergenceSessionHref, + pauseReason: null, + errorMessage: null, + lastStoppedAt: new Date().toISOString(), + }); await onRefreshRef.current(); } else { setActionError(res.error ?? "Auto-merge failed"); setAutoConverge(false); + saveConvergenceRuntime({ + status: "failed", + pollerStatus: "idle", + activeSessionId: null, + activeHref: convergenceSessionHref, + pauseReason: null, + errorMessage: res.error ?? "Auto-merge failed", + lastStoppedAt: new Date().toISOString(), + }); } } catch (err: unknown) { setActionError( err instanceof Error ? err.message : "Auto-merge failed", ); setAutoConverge(false); + saveConvergenceRuntime({ + status: "failed", + pollerStatus: "idle", + activeSessionId: null, + activeHref: convergenceSessionHref, + pauseReason: null, + errorMessage: err instanceof Error ? err.message : "Auto-merge failed", + lastStoppedAt: new Date().toISOString(), + }); } } else { // Checks not passing — cannot auto-merge setActionError("Auto-merge skipped: some checks are not passing"); setAutoConverge(false); + saveConvergenceRuntime({ + status: "failed", + pollerStatus: "idle", + activeSessionId: null, + activeHref: convergenceSessionHref, + pauseReason: null, + errorMessage: "Auto-merge skipped: some checks are not passing", + lastStoppedAt: new Date().toISOString(), + }); } } else { setAutoConverge(false); @@ -917,11 +1334,12 @@ export function PrDetailPane({ // Poll failed, schedule retry scheduleTick(); } - }, 60_000); // Poll every 60 seconds + }, delayMs); // Poll every delayMs (default 60 s) }; - scheduleTick(); - }, [pr.id, stopAutoConvergePoller]); + scheduleTick(0); + }, [convergenceSessionHref, pr.id, saveConvergenceRuntime, stopAutoConvergePoller]); + startAutoConvergePollerRef.current = startAutoConvergePoller; // Listen for agent session completion to start polling React.useEffect(() => { @@ -929,25 +1347,85 @@ export function PrDetailPane({ const unsubscribe = window.ade.prs.onAiResolutionEvent((event) => { if (event.sessionId !== convergenceSessionId) return; if (event.status === "completed" || event.status === "failed" || event.status === "cancelled") { - setConvergenceBusy(false); - setConvergenceSessionId(null); - void syncInventory(); - // Start polling if auto-converge is on and session completed successfully - if (autoConverge && event.status === "completed") { - startAutoConvergePoller(); - } + void handleConvergenceSessionTerminal({ + sessionId: event.sessionId, + status: event.status, + message: event.message, + }); } }); return unsubscribe; - }, [autoConverge, convergenceSessionId, startAutoConvergePoller, syncInventory]); + }, [convergenceSessionId, handleConvergenceSessionTerminal]); + + React.useEffect(() => { + stopConvergenceSessionPoller(); + if (!convergenceSessionId) return; + + let cancelled = false; + const pollSessionState = async () => { + try { + const detail = await window.ade.sessions.get(convergenceSessionId); + if (cancelled || convergenceSessionIdRef.current !== convergenceSessionId) return; + if (!detail || detail.status === "running") { + convergenceSessionPollerRef.current = window.setTimeout(() => { + void pollSessionState(); + }, 2_000); + return; + } + const terminalStatus: "completed" | "failed" | "disposed" = + detail.status === "completed" + ? "completed" + : detail.status === "disposed" + ? "disposed" + : "failed"; + void handleConvergenceSessionTerminal({ + sessionId: convergenceSessionId, + status: terminalStatus, + }); + } catch { + if (cancelled || convergenceSessionIdRef.current !== convergenceSessionId) return; + convergenceSessionPollerRef.current = window.setTimeout(() => { + void pollSessionState(); + }, 5_000); + } + }; + + void pollSessionState(); + return () => { + cancelled = true; + stopConvergenceSessionPoller(); + }; + }, [convergenceSessionId, handleConvergenceSessionTerminal, stopConvergenceSessionPoller]); + + React.useEffect(() => { + if (!autoConverge || convergenceSessionId) { + if (!convergenceSessionId) stopAutoConvergePoller(); + return; + } + if (autoConvergeWaitState.phase === "waiting_checks" || autoConvergeWaitState.phase === "waiting_comments") { + if (!autoConvergePollerRef.current) { + startAutoConvergePoller(); + } + return; + } + if ( + autoConvergeWaitState.phase === "idle" + || autoConvergeWaitState.phase === "paused" + || autoConvergeWaitState.phase === "complete" + || autoConvergeWaitState.phase === "merged" + ) { + stopAutoConvergePoller(); + } + }, [autoConverge, autoConvergeWaitState.phase, convergenceSessionId, startAutoConvergePoller, stopAutoConvergePoller]); // Cleanup poller on unmount React.useEffect(() => { return () => { if (autoConvergeTimerRef.current) clearTimeout(autoConvergeTimerRef.current); stopAutoConvergePoller(); + stopConvergenceSessionPoller(); }; - }, [stopAutoConvergePoller]); + }, [stopAutoConvergePoller, stopConvergenceSessionPoller]); const resolveIssueScope = React.useCallback((): "both" | "checks" | "comments" => { const a = issueResolutionAvailability; @@ -957,13 +1435,29 @@ export function PrDetailPane({ }, [issueResolutionAvailability]); const handleRunNextRound = React.useCallback(async (additionalInstructions: string) => { + const launchingAutoConverge = autoConverge; setConvergenceBusy(true); setActionError(null); + autoConvergeAdditionalRef.current = additionalInstructions; try { const snapshot = await syncInventory(); if (!snapshot) throw new Error("Failed to sync inventory"); const hasNew = snapshot.items.some((item) => item.state === "new"); if (!hasNew) { + if (launchingAutoConverge) { + setAutoConvergeWaitState({ phase: "complete" }); + saveConvergenceRuntime({ + autoConvergeEnabled: true, + status: "converged", + pollerStatus: "idle", + currentRound: snapshot.convergence.currentRound, + activeSessionId: null, + activeHref: convergenceSessionHrefRef.current, + pauseReason: null, + errorMessage: null, + lastStoppedAt: new Date().toISOString(), + }); + } setConvergenceBusy(false); return; } @@ -977,13 +1471,45 @@ export function PrDetailPane({ additionalInstructions, }); + const currentRound = snapshot.convergence.currentRound + 1; setConvergenceSessionId(result.sessionId); + setConvergenceSessionHref(result.href); + setAutoConvergeWaitState({ phase: "agent_running", sessionId: result.sessionId }); + setConvergencePauseReason(null); + setConvergenceMerged(false); + saveConvergenceRuntime({ + autoConvergeEnabled: launchingAutoConverge, + status: "running", + pollerStatus: "idle", + currentRound, + activeSessionId: result.sessionId, + activeLaneId: pr.laneId, + activeHref: result.href, + pauseReason: null, + errorMessage: null, + lastStartedAt: new Date().toISOString(), + }); void syncInventory(); } catch (err) { - setActionError(err instanceof Error ? err.message : "Failed to launch agent"); + const message = err instanceof Error ? err.message : "Failed to launch agent"; + setActionError(message); setConvergenceBusy(false); + if (launchingAutoConverge) { + setConvergencePauseReason(message); + setAutoConvergeWaitState({ phase: "paused", reason: message }); + saveConvergenceRuntime({ + autoConvergeEnabled: true, + status: "paused", + pollerStatus: "paused", + activeSessionId: null, + activeHref: convergenceSessionHrefRef.current, + pauseReason: message, + errorMessage: message, + lastPausedAt: new Date().toISOString(), + }); + } } - }, [pr.id, resolverModel, resolverPermissionMode, resolverReasoningLevel, syncInventory, resolveIssueScope]); + }, [autoConverge, pr.id, pr.laneId, resolverModel, resolverPermissionMode, resolverReasoningLevel, resolveIssueScope, saveConvergenceRuntime, syncInventory]); // Keep ref in sync for the auto-converge poller handleRunNextRoundRef.current = handleRunNextRound; @@ -1006,16 +1532,73 @@ export function PrDetailPane({ } }, [pr.id, resolverModel, resolverPermissionMode, resolverReasoningLevel, resolveIssueScope]); - const handleAutoConvergeToggle = React.useCallback((enabled: boolean) => { + const handleAutoConvergeToggle = React.useCallback(async (enabled: boolean) => { setAutoConverge(enabled); if (!enabled) { stopAutoConvergePoller(); + const activeSessionId = convergenceSessionIdRef.current; + if (activeSessionId) { + // Try to stop the running session. Only clear the session handle on + // confirmed success so the user retains the ability to retry if the + // stop call fails. + try { + await window.ade.prs.aiResolutionStop({ sessionId: activeSessionId }); + // Stop succeeded -- clear session handle and mark stopped. + setConvergenceBusy(false); + setConvergenceSessionId(null); + setConvergenceSessionHref(null); + setAutoConvergeWaitState({ phase: "idle" }); + setConvergencePauseReason(null); + saveConvergenceRuntime({ + autoConvergeEnabled: false, + status: "stopped", + pollerStatus: "stopped", + activeSessionId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStoppedAt: new Date().toISOString(), + }); + } catch (err: unknown) { + // Stop failed -- keep the session handle so the user can retry. + setActionError( + `Failed to stop session: ${err instanceof Error ? err.message : String(err)}`, + ); + saveConvergenceRuntime({ + autoConvergeEnabled: false, + status: "running", + pollerStatus: "idle", + activeSessionId, + activeHref: convergenceSessionHrefRef.current, + pauseReason: null, + errorMessage: err instanceof Error ? err.message : String(err), + }); + } + } else { + setAutoConvergeWaitState({ phase: "idle" }); + setConvergenceSessionHref(null); + setConvergencePauseReason(null); + saveConvergenceRuntime({ + autoConvergeEnabled: false, + status: "stopped", + pollerStatus: "stopped", + activeSessionId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStoppedAt: new Date().toISOString(), + }); + } if (autoConvergeTimerRef.current) { clearTimeout(autoConvergeTimerRef.current); autoConvergeTimerRef.current = null; } + } else { + saveConvergenceRuntime({ + autoConvergeEnabled: true, + }); } - }, [stopAutoConvergePoller]); + }, [saveConvergenceRuntime, stopAutoConvergePoller]); const handleMarkDismissed = React.useCallback(async (itemIds: string[], reason: string) => { try { @@ -1036,21 +1619,28 @@ export function PrDetailPane({ }, [pr.id, syncInventory]); const handleResetInventory = React.useCallback(async () => { - await window.ade.prs.issueInventoryReset(pr.id); - setInventorySnapshot(null); - setAutoConverge(false); - setConvergenceSessionId(null); - if (autoConvergeTimerRef.current) { - clearTimeout(autoConvergeTimerRef.current); - autoConvergeTimerRef.current = null; + try { + await window.ade.prs.issueInventoryReset(pr.id); + await resetConvergenceState(pr.id); + setInventorySnapshot(null); + setConvergenceBusy(false); + setAutoConverge(false); + setConvergenceSessionId(null); + setConvergenceSessionHref(null); + setConvergenceMerged(false); + setConvergencePauseReason(null); + setAutoConvergeWaitState({ phase: "idle" }); + await refreshDetailSurface({ includeInventory: true }); + } catch (err: unknown) { + setActionError(err instanceof Error ? err.message : String(err)); + } finally { + if (autoConvergeTimerRef.current) { + clearTimeout(autoConvergeTimerRef.current); + autoConvergeTimerRef.current = null; + } + stopAutoConvergePoller(); } - }, [pr.id]); - - const handleOpenConvergencePanel = React.useCallback(() => { - setShowConvergencePanel(true); - void loadDetail(); - void onRefresh(); - }, [loadDetail, onRefresh]); + }, [pr.id, refreshDetailSurface, resetConvergenceState, stopAutoConvergePoller]); const localBehindCount = laneForPr?.status?.behind ?? 0; @@ -1059,13 +1649,17 @@ export function PrDetailPane({ const rc = getPrReviewsBadge(pr.reviewStatus); const TAB_ACTIVE_COLORS: Record = { overview: COLORS.accent, + convergence: COLORS.accent, files: COLORS.info, checks: COLORS.success, activity: COLORS.warning, }; + const newIssueCount = inventorySnapshot?.items.filter(i => i.state === "new").length ?? 0; + const DETAIL_TABS: Array<{ id: DetailTab; label: string; icon: React.ElementType; count?: number }> = [ { id: "overview", label: "Overview", icon: Eye }, + { id: "convergence", label: "Path to Merge", icon: Sparkle, count: newIssueCount > 0 ? newIssueCount : undefined }, { id: "files", label: "Files", icon: Code, count: files.length }, { id: "checks", label: "CI / Checks", icon: Play, count: checks.length + actionRuns.reduce((sum, run) => sum + run.jobs.length, 0) }, { id: "activity", label: "Activity", icon: ClockCounterClockwise, count: activity.length > 0 ? activity.length : (comments.length + reviews.length) }, @@ -1177,16 +1771,7 @@ export function PrDetailPane({ {/* Right-side action buttons */}
- {issueResolutionAvailability.hasAnyActionableIssues ? ( - - ) : null} - {queueContext && onOpenQueueView ? ( @@ -1266,6 +1851,88 @@ export function PrDetailPane({ lanes={lanes} /> )} + {activeTab === "convergence" && ( + { + const prev = pipelineSettings; + const next = { ...pipelineSettings, ...partial }; + setPipelineSettings(next); + pipelineSettingsRef.current = next; + window.ade.prs.pipelineSettingsSave(pr.id, partial).catch((err: unknown) => { + setPipelineSettings(prev); + pipelineSettingsRef.current = prev; + setActionError(err instanceof Error ? err.message : String(err)); + }); + }} + onModelChange={setResolverModel} + onReasoningEffortChange={setResolverReasoningLevel} + onPermissionModeChange={setResolverPermissionMode} + onRunNextRound={handleRunNextRound} + onAutoConvergeChange={handleAutoConvergeToggle} + onCopyPrompt={handleConvergenceCopyPrompt} + onMarkDismissed={handleMarkDismissed} + onMarkEscalated={handleMarkEscalated} + onResetInventory={handleResetInventory} + onViewAgentSession={(sessionId) => { + const href = convergenceSessionHref + ?? (sessionId.startsWith("http://") || sessionId.startsWith("https://") || sessionId.startsWith("/") + ? sessionId + : (pr.laneId ? buildSessionHref(pr.laneId, sessionId) : null)); + if (href && onNavigate) { + onNavigate(href); + } + }} + onStopAutoConverge={() => handleAutoConvergeToggle(false)} + onResumePause={() => { + setConvergencePauseReason(null); + setAutoConvergeWaitState({ phase: "idle" }); + behindCountRef.current = 0; + saveConvergenceRuntime({ + status: "polling", + pollerStatus: "scheduled", + pauseReason: null, + }); + startAutoConvergePoller(); + }} + onDismissPause={() => { + setConvergencePauseReason(null); + setAutoConvergeWaitState({ phase: "idle" }); + behindCountRef.current = 0; + setAutoConverge(false); + saveConvergenceRuntime({ + autoConvergeEnabled: false, + status: "stopped", + pollerStatus: "stopped", + pauseReason: null, + errorMessage: null, + }); + }} + onDismissMerged={() => { + setConvergenceMerged(false); + setAutoConvergeWaitState({ phase: "idle" }); + saveConvergenceRuntime({ + status: "idle", + pollerStatus: "idle", + pauseReason: null, + errorMessage: null, + }); + }} + /> + )} {activeTab === "files" && ( )} @@ -1314,52 +1981,6 @@ export function PrDetailPane({ onLaunch={handleLaunchIssueResolver} onCopyPrompt={handleCopyIssueResolverPrompt} /> - { - const next = { ...pipelineSettings, ...partial }; - setPipelineSettings(next); - pipelineSettingsRef.current = next; - void window.ade.prs.pipelineSettingsSave(pr.id, partial); - }} - onOpenChange={setShowConvergencePanel} - onModelChange={setResolverModel} - onReasoningEffortChange={setResolverReasoningLevel} - onPermissionModeChange={setResolverPermissionMode} - onRunNextRound={handleRunNextRound} - onAutoConvergeChange={handleAutoConvergeToggle} - onCopyPrompt={handleConvergenceCopyPrompt} - onMarkDismissed={handleMarkDismissed} - onMarkEscalated={handleMarkEscalated} - onResetInventory={handleResetInventory} - pauseReason={convergencePauseReason} - onResumePause={() => { - setConvergencePauseReason(null); - behindCountRef.current = 0; - startAutoConvergePoller(); - }} - onDismissPause={() => { - setConvergencePauseReason(null); - behindCountRef.current = 0; - setAutoConverge(false); - }} - convergenceMerged={convergenceMerged} - onDismissMerged={() => setConvergenceMerged(false)} - />
); } @@ -1586,7 +2207,7 @@ type OverviewTabProps = { }; function OverviewTab(props: OverviewTabProps) { - const { pr, detail, status, checks, reviews, comments, detailBusy, aiSummary, aiSummaryBusy, actionBusy, mergeMethod, activity, lanes } = props; + const { pr, detail, status, checks, reviews, comments, aiSummary, aiSummaryBusy, actionBusy, mergeMethod, activity, lanes } = props; const [checksExpanded, setChecksExpanded] = React.useState(false); const [localMergeMethod, setLocalMergeMethod] = React.useState(mergeMethod); const [allowBlockedMerge, setAllowBlockedMerge] = React.useState(false); @@ -1616,7 +2237,7 @@ function OverviewTab(props: OverviewTabProps) { // Checks summary const checksSummary = summarizeChecks(checks); - const { allChecksPassed, someChecksFailing, checksRunning } = checksSummary; + const { someChecksFailing, checksRunning } = checksSummary; const checksRowVisuals = getChecksRowVisuals(checksSummary); // Review status from pr @@ -2652,10 +3273,6 @@ function ChecksTab({ checks, actionRuns, actionBusy, onRerunChecks, showIssueRes {isExpanded && hasSteps && (
{item.steps!.map((step) => { - const stepColor = step.conclusion === "success" ? COLORS.success - : step.conclusion === "failure" ? COLORS.danger - : step.conclusion === "skipped" ? COLORS.textDim - : COLORS.warning; return (
{step.conclusion === "success" ? : diff --git a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx new file mode 100644 index 000000000..a1d0355fe --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx @@ -0,0 +1,205 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AiPermissionMode, PipelineSettings, PrCheck } from "../../../../shared/types"; +import type { + AutoConvergeWaitState, + ConvergenceStatus, + IssueInventoryItem, + PrConvergencePanelProps, +} from "./PrConvergencePanel"; +import { PrConvergencePanel } from "./PrConvergencePanel"; + +vi.mock("./PrPipelineSettings", () => ({ + PrPipelineSettings: ({ + showAutoConvergeSettings, + }: { + showAutoConvergeSettings: boolean; + }) => ( +
+ {showAutoConvergeSettings ? "auto-converge-settings" : "manual-settings"} +
+ ), +})); + +function makeItem(overrides: Partial = {}): IssueInventoryItem { + return { + id: "item-1", + state: "new", + severity: "major", + headline: "Tighten convergence state restoration", + filePath: "src/prs.ts", + line: 42, + source: "coderabbit", + dismissReason: null, + agentSessionId: null, + ...overrides, + }; +} + +function makeCheck(overrides: Partial = {}): PrCheck { + return { + name: "ci / unit", + status: "completed", + conclusion: "failure", + detailsUrl: null, + startedAt: null, + completedAt: null, + ...overrides, + }; +} + +function makeConvergence(overrides: Partial = {}): ConvergenceStatus { + return { + state: "not_started", + currentRound: 1, + maxRounds: 5, + ...overrides, + }; +} + +const defaultPipelineSettings: PipelineSettings = { + autoMerge: false, + mergeMethod: "repo_default", + maxRounds: 5, + onRebaseNeeded: "pause", +}; + +function renderPanel(overrides: Partial = {}) { + const props: PrConvergencePanelProps = { + prNumber: 117, + prTitle: "Persist convergence runtime state", + headBranch: "feature/path-to-merge", + baseBranch: "main", + items: [], + convergence: makeConvergence(), + checks: [], + modelId: "openai/gpt-5.4-codex", + reasoningEffort: "high", + permissionMode: "guarded_edit" as AiPermissionMode, + busy: false, + autoConverge: false, + pipelineSettings: defaultPipelineSettings, + waitState: { phase: "idle" }, + onPipelineSettingsChange: vi.fn(), + onModelChange: vi.fn(), + onReasoningEffortChange: vi.fn(), + onPermissionModeChange: vi.fn(), + onRunNextRound: vi.fn(async () => undefined), + onAutoConvergeChange: vi.fn(), + onCopyPrompt: vi.fn(async () => undefined), + onMarkDismissed: vi.fn(), + onMarkEscalated: vi.fn(), + onResetInventory: vi.fn(), + onViewAgentSession: vi.fn(), + onStopAutoConverge: vi.fn(), + onResumePause: vi.fn(), + onDismissPause: vi.fn(), + onDismissMerged: vi.fn(), + ...overrides, + }; + + render(); + return props; +} + +describe("PrConvergencePanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders the empty-state copy when no issues or checks are available", () => { + renderPanel(); + + expect(screen.getByText("No issues inventoried yet.")).toBeTruthy(); + expect(screen.getByText(/Sync review comments and CI checks to start the convergence loop/i)).toBeTruthy(); + }); + + it("calls onRunNextRound with the typed additional instructions", async () => { + const user = userEvent.setup(); + const props = renderPanel({ + items: [makeItem()], + checks: [makeCheck({ conclusion: "success" })], + }); + + await user.type(screen.getByPlaceholderText("Add instructions for this round..."), "focus on review threads"); + await user.click(screen.getByRole("button", { name: "Launch Agent" })); + + expect(props.onRunNextRound).toHaveBeenCalledWith("focus on review threads"); + }); + + it("copies the prompt with additional instructions", async () => { + const user = userEvent.setup(); + const props = renderPanel({ + items: [makeItem()], + checks: [makeCheck({ conclusion: "success" })], + }); + + await user.type(screen.getByPlaceholderText("Add instructions for this round..."), "rerun failed checks only if needed"); + await user.click(screen.getByRole("button", { name: "Copy Prompt" })); + + expect(props.onCopyPrompt).toHaveBeenCalledWith("rerun failed checks only if needed"); + }); + + it("shows the auto-converge waiting banner and deep-links to the active session", async () => { + const user = userEvent.setup(); + const props = renderPanel({ + autoConverge: true, + items: [makeItem()], + convergence: makeConvergence({ state: "converging", currentRound: 1 }), + waitState: { phase: "agent_running", sessionId: "session-123" }, + }); + + expect(screen.getByText("Agent working on round 2...")).toBeTruthy(); + expect(screen.getByTestId("pipeline-settings").textContent).toContain("auto-converge-settings"); + + await user.click(screen.getAllByRole("button", { name: /View Session/i })[0]!); + expect(props.onViewAgentSession).toHaveBeenCalledWith("session-123"); + }); + + it("allows dismissing and escalating unresolved review items", async () => { + const user = userEvent.setup(); + const props = renderPanel({ + items: [ + makeItem({ id: "issue-1", headline: "Address unresolved review feedback" }), + ], + }); + + await user.click(screen.getByTitle("Dismiss")); + expect(props.onMarkDismissed).toHaveBeenCalledWith(["issue-1"], "Dismissed from UI"); + + await user.click(screen.getByTitle("Escalate")); + expect(props.onMarkEscalated).toHaveBeenCalledWith(["issue-1"]); + }); + + it("resets inventory from the review comments header", async () => { + const user = userEvent.setup(); + const props = renderPanel({ + items: [makeItem()], + }); + + await user.click(screen.getByTitle("Reset inventory")); + expect(props.onResetInventory).toHaveBeenCalledTimes(1); + }); + + it("shows pause controls and routes them to the provided handlers", async () => { + const user = userEvent.setup(); + const props = renderPanel({ + autoConverge: true, + items: [], + convergence: makeConvergence({ state: "stalled" }), + waitState: { phase: "paused", reason: "Rebase needed" } satisfies AutoConvergeWaitState, + }); + + expect(screen.getByText("Paused: Rebase needed")).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: "Resume" })); + expect(props.onResumePause).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Dismiss" })); + expect(props.onDismissPause).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx index 78bc1da8c..3c2d2aacd 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx @@ -1,6 +1,5 @@ import React from "react"; import { - X, CircleNotch, CheckCircle, Warning, @@ -10,8 +9,8 @@ import { Eye, Trash, ArrowUp, - GitBranch, Play, + Stop, } from "@phosphor-icons/react"; import type { AiPermissionMode, PipelineSettings, PrCheck } from "../../../../shared/types"; import { @@ -22,7 +21,6 @@ import { primaryButton, } from "../../lanes/laneDesignTokens"; import { PrPipelineSettings } from "./PrPipelineSettings"; -import { AgentChatPane } from "../../chat/AgentChatPane"; // --------------------------------------------------------------------------- // Types @@ -50,8 +48,17 @@ export type ConvergenceStatus = { maxRounds: number; }; +export type AutoConvergeWaitState = + | { phase: "idle" } + | { phase: "agent_running"; sessionId: string } + | { phase: "waiting_checks"; pendingCount: number; totalCount: number } + | { phase: "waiting_comments"; stablePollCount: number } + | { phase: "ready" } + | { phase: "paused"; reason: string } + | { phase: "complete" } + | { phase: "merged" }; + export type PrConvergencePanelProps = { - open: boolean; prNumber: number; prTitle: string; headBranch: string; @@ -63,11 +70,10 @@ export type PrConvergencePanelProps = { reasoningEffort: string; permissionMode: AiPermissionMode; busy: boolean; - agentSessionId: string | null; autoConverge: boolean; pipelineSettings: PipelineSettings; + waitState: AutoConvergeWaitState; onPipelineSettingsChange: (settings: Partial) => void; - onOpenChange: (open: boolean) => void; onModelChange: (modelId: string) => void; onReasoningEffortChange: (value: string) => void; onPermissionModeChange: (mode: AiPermissionMode) => void; @@ -77,10 +83,10 @@ export type PrConvergencePanelProps = { onMarkDismissed: (itemIds: string[], reason: string) => void; onMarkEscalated: (itemIds: string[]) => void; onResetInventory: () => void; - pauseReason?: string | null; + onViewAgentSession?: (sessionId: string) => void; + onStopAutoConverge?: () => void; onResumePause?: () => void; onDismissPause?: () => void; - convergenceMerged?: boolean; onDismissMerged?: () => void; }; @@ -238,7 +244,7 @@ function RoundIndicator({ current, max }: { current: number; max: number }) { else if (isCurrent) dotColor = COLORS.accent; return (
void; - remainingRounds: number; - disabled: boolean; + waitState: AutoConvergeWaitState; + convergence: ConvergenceStatus; + onViewSession?: (sessionId: string) => void; + onResumePause?: () => void; + onDismissPause?: () => void; + onDismissMerged?: () => void; + onStop?: () => void; }) { - return ( -
- -
- - Auto-Converge - - {enabled ? ( - - Will auto-run up to {remainingRounds} more round{remainingRounds !== 1 ? "s" : ""} +
+ + Agent working on round {convergence.currentRound + 1}... +
+ {onViewSession && ( + + )} +
+ ); + } + + if (waitState.phase === "waiting_checks") { + return ( +
+
+ + + Waiting for {waitState.pendingCount} of {waitState.totalCount} CI checks to complete - ) : null} +
+ {onStop && ( + + )}
-
- ); + ); + } + + if (waitState.phase === "waiting_comments") { + return ( +
+
+
+ + Waiting for review comments to settle +
+ + (poll {waitState.stablePollCount}/2 — comments must remain stable for ~2 min) + +
+ {onStop && ( + + )} +
+ ); + } + + if (waitState.phase === "ready") { + return ( +
+
+ + Ready to launch round {convergence.currentRound + 1} +
+
+ ); + } + + if (waitState.phase === "paused") { + return ( +
+
+ + Paused: {waitState.reason} +
+
+ {onResumePause && ( + + )} + {onDismissPause && ( + + )} +
+
+ ); + } + + if (waitState.phase === "complete") { + return ( +
+
+ + Convergence complete — all issues resolved +
+
+ ); + } + + if (waitState.phase === "merged") { + return ( +
+
+ + Merged! PR was auto-merged after convergence. +
+ {onDismissMerged && ( + + )} +
+ ); + } + + return null; } // --------------------------------------------------------------------------- @@ -750,11 +979,6 @@ function AutoConvergeSwitch({ // --------------------------------------------------------------------------- export function PrConvergencePanel({ - open, - prNumber, - prTitle, - headBranch, - baseBranch, items, convergence, checks, @@ -762,11 +986,10 @@ export function PrConvergencePanel({ reasoningEffort, permissionMode, busy, - agentSessionId, autoConverge, pipelineSettings, + waitState, onPipelineSettingsChange, - onOpenChange, onModelChange, onReasoningEffortChange, onPermissionModeChange, @@ -776,62 +999,23 @@ export function PrConvergencePanel({ onMarkDismissed, onMarkEscalated, onResetInventory, - pauseReason, + onViewAgentSession, + onStopAutoConverge, onResumePause, onDismissPause, - convergenceMerged, onDismissMerged, }: PrConvergencePanelProps) { const [additionalInstructions, setAdditionalInstructions] = React.useState(""); - const scrollRef = React.useRef(null); - const previousFocusRef = React.useRef(null); + const [mode, setMode] = React.useState<"manual" | "auto-converge">(autoConverge ? "auto-converge" : "manual"); React.useEffect(() => { ensureKeyframes(); }, []); React.useEffect(() => { - if (open) { - setAdditionalInstructions(""); - } - }, [open]); - - // Focus management: capture previously focused element and focus the dialog - React.useEffect(() => { - if (!open) return; - - previousFocusRef.current = document.activeElement; - - // Focus the dialog container on next frame so the ref is attached - requestAnimationFrame(() => { - scrollRef.current?.focus(); - }); - - return () => { - // Restore focus to the previously focused element on unmount - if (previousFocusRef.current instanceof HTMLElement) { - previousFocusRef.current.focus(); - } - }; - }, [open]); - - // Escape key handler - React.useEffect(() => { - if (!open) return; - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape" && !busy) { - e.stopPropagation(); - onOpenChange(false); - } - } - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [open, busy, onOpenChange]); - - - if (!open) return null; + if (autoConverge && mode !== "auto-converge") setMode("auto-converge"); + if (!autoConverge && mode === "auto-converge") setMode("manual"); + }, [autoConverge, mode]); // Group items by state const grouped: Record = { @@ -848,7 +1032,9 @@ export function PrConvergencePanel({ const reviewCommentItems = [...grouped.escalated, ...grouped.new, ...grouped.in_progress, ...grouped.fixed, ...grouped.dismissed]; const failingChecks = checks.filter((c) => c.conclusion === "failure"); const runningChecks = checks.filter((c) => c.status === "in_progress"); - const allChecksPassing = failingChecks.length === 0 && runningChecks.length === 0; + const queuedChecks = checks.filter((c) => c.status === "queued"); + const checksStillRunning = queuedChecks.length > 0 || runningChecks.length > 0; + const allChecksPassing = checks.length > 0 && checks.every((c) => c.conclusion === "success"); const passingChecks = checks.filter((c) => c.conclusion === "success"); const otherChecks = checks.filter( (c) => c.conclusion !== "failure" && c.conclusion !== "success" && c.status !== "in_progress", @@ -857,423 +1043,381 @@ export function PrConvergencePanel({ const hasNewItems = grouped.new.length > 0; const atMaxRounds = convergence.currentRound >= convergence.maxRounds; - const canRunNext = hasNewItems && !atMaxRounds && !busy; - const remainingRounds = Math.max(0, convergence.maxRounds - convergence.currentRound); + const hasActiveWaitState = waitState.phase === "agent_running" || waitState.phase === "waiting_checks" || waitState.phase === "waiting_comments" || waitState.phase === "paused"; + const canRunNext = hasNewItems && !atMaxRounds && !busy && !checksStillRunning && !hasActiveWaitState; + + const launchDisabledReason = !hasNewItems ? "No new issues to resolve" + : atMaxRounds ? "Maximum rounds reached" + : busy ? "Agent is currently running" + : waitState.phase === "agent_running" ? "A convergence session is already running" + : waitState.phase === "waiting_checks" ? "Waiting for CI checks to finish" + : waitState.phase === "waiting_comments" ? "Waiting for review comments to settle" + : waitState.phase === "paused" ? "Convergence session is paused" + : checksStillRunning ? "CI checks are still running" + : null; - const truncatedTitle = - prTitle.length > 60 ? `${prTitle.slice(0, 59)}...` : prTitle; + const isEmpty = items.length === 0 && checks.length === 0; return (
{ - if (!busy) onOpenChange(false); + flexDirection: "column", + height: "100%", + background: COLORS.pageBg, + overflow: "hidden", }} > + {/* ---- Toolbar ---- */}
e.stopPropagation()} style={{ - width: "min(1200px, calc(100vw - 40px))", - maxHeight: "min(800px, calc(100vh - 64px))", display: "flex", - flexDirection: "column", - background: "#0F0D14", - border: `1px solid rgba(255,255,255,0.07)`, - borderRadius: 18, - boxShadow: "0 40px 120px rgba(0,0,0,0.7), 0 0 1px rgba(255,255,255,0.08) inset", - overflow: "hidden", - animation: "convergeFadeIn 0.2s ease-out", - outline: "none", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + padding: "10px 16px", + borderBottom: `1px solid ${COLORS.border}`, + flexShrink: 0, }} > - {/* ---- Header ---- */} + {/* Left: Segmented control */}
-
-
- - Path to Merge - - {autoConverge && ( - <> - - - - - )} -
- -
- { + const active = mode === m; + return ( +
+ {m === "manual" ? "Manual" : "Auto-Converge"} + + ); + })} +
-
- - - {headBranch} - - - into - - - {baseBranch} - -
+ {/* Right: Round indicator + status pill (auto-converge only) */} + {mode === "auto-converge" && ( +
+ +
+ )} +
- -
- - {/* ---- Stats bar ---- */} - + {/* ---- Stats bar ---- */} + - {/* ---- Three-column body ---- */} -
- {/* Left: Review Comments */} + {/* ---- Scrollable content ---- */} +
+ {isEmpty ? (
+ + No issues inventoried yet. + + + Sync review comments and CI checks to start the convergence loop. + Run the first round to discover actionable issues. + +
+ ) : ( + <> + {/* Two-column grid: Review Comments | CI Checks */}
- - - Review Comments - - - {reviewCommentItems.length} - - {reviewCommentItems.length > 0 ? ( - - ) : null} -
-
- {reviewCommentItems.length > 0 ? ( - <> - {STATE_ORDER.map((state) => { - const stateItems = grouped[state]; - if (stateItems.length === 0) return null; - const meta = STATE_META[state]; - return ( -
-
- - {state === "in_progress" ? ( - - {meta.icon} - - ) : ( - meta.icon - )} - - - {meta.label} - - - {stateItems.length} - -
-
- {stateItems.map((item) => { - if (state === "fixed") return ; - if (state === "dismissed") return ; - return ( - onMarkDismissed([id], "Dismissed from UI")} - onEscalate={(id) => onMarkEscalated([id])} - /> - ); - })} -
-
- ); - })} - - ) : (
+ - No issues have been inventoried yet. Run the first round to discover issues from review comments. + Review Comments + + {reviewCommentItems.length} + + {reviewCommentItems.length > 0 ? ( + + ) : null}
- )} -
-
+
+ {reviewCommentItems.length > 0 ? ( + <> + {STATE_ORDER.map((state) => { + const stateItems = grouped[state]; + if (stateItems.length === 0) return null; + const meta = STATE_META[state]; + return ( +
+
+ + {state === "in_progress" ? ( + + {meta.icon} + + ) : ( + meta.icon + )} + + + {meta.label} + + + {stateItems.length} + +
+
+ {stateItems.map((item) => { + if (state === "fixed") return ; + if (state === "dismissed") return ; + return ( + onMarkDismissed([id], "Dismissed from UI")} + onEscalate={(id) => onMarkEscalated([id])} + /> + ); + })} +
+
+ ); + })} + + ) : ( +
+ + No issues have been inventoried yet. Run the first round to discover issues from review comments. + +
+ )} +
+
- {/* Middle: CI Checks */} -
-
- - - CI Checks - - - {allChecksPassing ? "all passing" : `${failingChecks.length} failing`} - -
-
- {orderedChecks.length > 0 ? ( -
- {orderedChecks.map((check) => ( - - ))} -
- ) : (
+ 0 ? COLORS.danger : COLORS.warning }} /> + CI Checks + + 0 ? COLORS.danger : COLORS.warning, }} > - No CI checks found. + {checks.length === 0 + ? "no checks found" + : allChecksPassing + ? "all passing" + : failingChecks.length > 0 + ? `${failingChecks.length} failing` + : queuedChecks.length > 0 && runningChecks.length > 0 + ? `${queuedChecks.length} queued, ${runningChecks.length} running` + : queuedChecks.length > 0 + ? `${queuedChecks.length} queued` + : `${runningChecks.length} running`}
- )} +
+ {orderedChecks.length > 0 ? ( +
+ {orderedChecks.map((check) => ( + + ))} +
+ ) : ( +
+ + No CI checks found. + +
+ )} +
+
-
- {/* Right: Settings column */} -
{/* Pipeline Settings */}
+ + )} +
- {/* Agent session embed */} - {agentSessionId ? ( -
-
- - - Agent Session - -
-
- -
-
- ) : null} -
-
- - {/* ---- Pause banner (rebase needed) ---- */} - {pauseReason ? ( -
-
- - - Paused: {pauseReason} - -
-
- {onResumePause && ( - - )} - {onDismissPause && ( - - )} -
-
- ) : null} - - {/* ---- Merged celebration banner ---- */} - {convergenceMerged ? ( -
-
- - - Merged! PR was auto-merged after convergence completed. - -
- {onDismissMerged && ( - - )} -
- ) : null} - - {/* ---- Sticky action bar ---- */} -
-
- {autoConverge && ( - - Auto-Converge - - )} - {pipelineSettings.autoMerge && ( - - Auto-Merge - - )} -
+ {/* ---- Waiting indicator ---- */} + {(mode === "auto-converge" || waitState.phase !== "idle") && ( + + )} -
+ {/* ---- Action bar ---- */} +
+
+ {(mode === "auto-converge" || waitState.phase !== "idle") && ( + )} + {autoConverge && ( + + Auto-Converge + + )} + {pipelineSettings.autoMerge && ( + + Auto-Merge + + )} +
+ +
+ {waitState.phase === "agent_running" && onViewAgentSession && ( -
+ )} + +
diff --git a/apps/desktop/src/renderer/components/prs/shared/PrPipelineSettings.tsx b/apps/desktop/src/renderer/components/prs/shared/PrPipelineSettings.tsx index c9372aeb0..39ad05ea0 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrPipelineSettings.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrPipelineSettings.tsx @@ -15,8 +15,7 @@ import { PrResolverLaunchControls } from "./PrResolverLaunchControls"; export type PrPipelineSettingsProps = { settings: PipelineSettings; onSettingsChange: (settings: Partial) => void; - autoConverge: boolean; - onAutoConvergeChange: (enabled: boolean) => void; + showAutoConvergeSettings?: boolean; modelId: string; reasoningEffort: string; permissionMode: AiPermissionMode; @@ -240,8 +239,7 @@ function StyledSelect({ export function PrPipelineSettings({ settings, onSettingsChange, - autoConverge, - onAutoConvergeChange, + showAutoConvergeSettings = true, modelId, reasoningEffort, permissionMode, @@ -260,47 +258,8 @@ export function PrPipelineSettings({ return (
- {/* Auto-Converge Toggle */} -
-
- - Auto-Converge - - - Automatically run rounds until all issues are resolved - -
- -
- {/* --- Auto-converge-only settings (hidden when off) --- */} - {autoConverge && ( + {showAutoConvergeSettings && ( <> {/* Auto-Merge Toggle */}
= {}): RebaseNeed { + return { + laneId: overrides.laneId ?? "lane-1", + laneName: overrides.laneName ?? "Lane 1", + kind: overrides.kind ?? "lane_base", + baseBranch: overrides.baseBranch ?? "main", + behindBy: overrides.behindBy ?? 1, + conflictPredicted: overrides.conflictPredicted ?? false, + conflictingFiles: overrides.conflictingFiles ?? [], + prId: overrides.prId ?? null, + groupContext: overrides.groupContext ?? null, + dismissedAt: overrides.dismissedAt ?? null, + deferredUntil: overrides.deferredUntil ?? null, + }; +} + +function makeStatus(overrides: Partial = {}): AutoRebaseLaneStatus { + return { + laneId: overrides.laneId ?? "lane-1", + parentLaneId: overrides.parentLaneId ?? "lane-parent", + parentHeadSha: overrides.parentHeadSha ?? "abc123", + state: overrides.state ?? "rebasePending", + updatedAt: overrides.updatedAt ?? "2026-04-01T12:00:00.000Z", + conflictCount: overrides.conflictCount ?? 0, + message: overrides.message ?? "Waiting on ancestor lane.", + }; +} + +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: overrides.id ?? "lane-1", + name: overrides.name ?? "Lane 1", + description: overrides.description ?? null, + laneType: overrides.laneType ?? "worktree", + baseRef: overrides.baseRef ?? "refs/heads/main", + branchRef: overrides.branchRef ?? "refs/heads/lane-1", + worktreePath: overrides.worktreePath ?? "/tmp/lane-1", + attachedRootPath: overrides.attachedRootPath ?? null, + parentLaneId: overrides.parentLaneId ?? "lane-parent", + childCount: overrides.childCount ?? 0, + stackDepth: overrides.stackDepth ?? 1, + parentStatus: overrides.parentStatus ?? null, + isEditProtected: overrides.isEditProtected ?? false, + status: overrides.status ?? { + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: 0, + rebaseInProgress: false, + }, + color: overrides.color ?? null, + icon: overrides.icon ?? null, + tags: overrides.tags ?? [], + folder: overrides.folder ?? null, + missionId: overrides.missionId ?? null, + laneRole: overrides.laneRole ?? null, + createdAt: overrides.createdAt ?? "2026-04-01T11:00:00.000Z", + archivedAt: overrides.archivedAt ?? null, + }; +} + +describe("filterRebaseAttentionStatuses", () => { + it("keeps active chain attention when the lane has no visible direct need", () => { + const statuses = [ + makeStatus({ laneId: "grandchild", state: "rebasePending" }), + ]; + + expect( + filterRebaseAttentionStatuses({ + autoRebaseStatuses: statuses, + visibleRebaseNeeds: [], + view: "active", + }), + ).toEqual(statuses); + }); + + it("hides attention statuses when the same lane already has a visible direct need", () => { + const statuses = [ + makeStatus({ laneId: "child", state: "rebaseConflict" }), + ]; + const needs = [ + makeNeed({ laneId: "child", kind: "lane_base", baseBranch: "feature/parent" }), + ]; + + expect( + filterRebaseAttentionStatuses({ + autoRebaseStatuses: statuses, + visibleRebaseNeeds: needs, + view: "active", + }), + ).toEqual([]); + }); + + it("sorts active statuses by severity before recency", () => { + const pending = makeStatus({ laneId: "pending", state: "rebasePending", updatedAt: "2026-04-01T12:00:00.000Z" }); + const failed = makeStatus({ laneId: "failed", state: "rebaseFailed", updatedAt: "2026-04-01T11:00:00.000Z" }); + const conflict = makeStatus({ laneId: "conflict", state: "rebaseConflict", updatedAt: "2026-04-01T10:00:00.000Z" }); + + expect( + filterRebaseAttentionStatuses({ + autoRebaseStatuses: [pending, failed, conflict], + visibleRebaseNeeds: [], + view: "active", + }).map((status) => status.laneId), + ).toEqual(["conflict", "failed", "pending"]); + }); + + it("keeps auto-rebased statuses only in history view", () => { + const statuses = [ + makeStatus({ laneId: "recent", state: "autoRebased" }), + makeStatus({ laneId: "pending", state: "rebasePending" }), + ]; + + expect( + filterRebaseAttentionStatuses({ + autoRebaseStatuses: statuses, + visibleRebaseNeeds: [], + view: "history", + }).map((status) => status.laneId), + ).toEqual(["recent"]); + }); +}); + +describe("findRebaseAttentionStatus", () => { + it("matches raw lane ids used for attention-only selections", () => { + const status = makeStatus({ laneId: "lane-grandchild" }); + expect(findRebaseAttentionStatus([status], "lane-grandchild")).toEqual(status); + }); + + it("matches prefixed attention selections", () => { + const status = makeStatus({ laneId: "lane-grandchild" }); + expect(findRebaseAttentionStatus([status], "attention:lane-grandchild")).toEqual(status); + }); + + it("returns null when the selected item id is empty", () => { + expect(findRebaseAttentionStatus([], " ")).toBeNull(); + }); +}); + +describe("buildRebaseAttentionItems", () => { + it("surfaces chain attention with parent trail context while excluding direct needs", () => { + const lanes = [ + makeLane({ id: "root", name: "Root", parentLaneId: null, stackDepth: 0 }), + makeLane({ id: "parent", name: "Parent", parentLaneId: "root", stackDepth: 1 }), + makeLane({ id: "child", name: "Child", parentLaneId: "parent", stackDepth: 2 }), + ]; + const items = buildRebaseAttentionItems({ + autoRebaseStatuses: [ + makeStatus({ laneId: "child", parentLaneId: "parent", state: "rebasePending" }), + ], + lanes, + visibleRebaseNeeds: [], + view: "active", + }); + + expect(items).toHaveLength(1); + expect(items[0]?.chainTrail).toEqual(["Root", "Parent", "Child"]); + expect(items[0]?.parentLaneName).toBe("Parent"); + expect(formatRebaseAttentionSummary(items[0]!)).toContain("waiting for Parent"); + }); + + it("hides attention items when the same lane already has a visible direct need", () => { + const lanes = [makeLane({ id: "child", name: "Child", parentLaneId: "parent" })]; + expect( + buildRebaseAttentionItems({ + autoRebaseStatuses: [makeStatus({ laneId: "child", parentLaneId: "parent", state: "rebaseConflict" })], + lanes, + visibleRebaseNeeds: [makeNeed({ laneId: "child", kind: "lane_base" })], + view: "active", + }), + ).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/components/prs/shared/rebaseAttentionUtils.ts b/apps/desktop/src/renderer/components/prs/shared/rebaseAttentionUtils.ts new file mode 100644 index 000000000..9c0fcd425 --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/shared/rebaseAttentionUtils.ts @@ -0,0 +1,131 @@ +import type { AutoRebaseLaneStatus, LaneSummary, RebaseNeed } from "../../../../shared/types"; + +export type RebaseAttentionState = Exclude; + +export type RebaseAttentionItem = { + key: string; + laneId: string; + laneName: string; + parentLaneId: string | null; + parentLaneName: string | null; + state: RebaseAttentionState; + updatedAt: string; + conflictCount: number; + message: string | null; + stackDepth: number | null; + chainTrail: string[]; +}; + +function severity(value: AutoRebaseLaneStatus["state"]): number { + if (value === "rebaseConflict") return 0; + if (value === "rebaseFailed") return 1; + if (value === "rebasePending") return 2; + return 3; +} + +function buildChainTrail(lanes: LaneSummary[], laneId: string): string[] { + const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const trail: string[] = []; + const visited = new Set(); + let current = laneById.get(laneId) ?? null; + + while (current && !visited.has(current.id)) { + visited.add(current.id); + trail.push(current.name); + if (!current.parentLaneId) break; + current = laneById.get(current.parentLaneId) ?? null; + } + + if (!trail.length) return [laneId]; + return trail.reverse(); +} + +function filterStatuses( + autoRebaseStatuses: AutoRebaseLaneStatus[], + visibleRebaseNeeds: RebaseNeed[], + view: "active" | "history", +): AutoRebaseLaneStatus[] { + const laneIdsWithVisibleNeeds = new Set(visibleRebaseNeeds.map((need) => need.laneId)); + return autoRebaseStatuses.filter((status) => { + if (laneIdsWithVisibleNeeds.has(status.laneId)) return false; + if (view === "active") return status.state !== "autoRebased"; + return status.state === "autoRebased"; + }); +} + +function isRenderableRebaseAttentionStatus( + status: AutoRebaseLaneStatus, +): status is AutoRebaseLaneStatus & { state: RebaseAttentionState } { + return status.state !== "autoRebased"; +} + +export function filterRebaseAttentionStatuses(args: { + autoRebaseStatuses: AutoRebaseLaneStatus[]; + visibleRebaseNeeds: RebaseNeed[]; + view: "active" | "history"; +}): AutoRebaseLaneStatus[] { + return filterStatuses(args.autoRebaseStatuses, args.visibleRebaseNeeds, args.view) + .sort((a, b) => { + const severityDelta = severity(a.state) - severity(b.state); + if (severityDelta !== 0) return severityDelta; + return Date.parse(b.updatedAt) - Date.parse(a.updatedAt); + }); +} + +export function buildRebaseAttentionItems(args: { + autoRebaseStatuses: AutoRebaseLaneStatus[]; + lanes: LaneSummary[]; + visibleRebaseNeeds: RebaseNeed[]; + view: "active" | "history"; +}): RebaseAttentionItem[] { + return filterStatuses(args.autoRebaseStatuses, args.visibleRebaseNeeds, args.view) + .filter(isRenderableRebaseAttentionStatus) + .map((status) => { + const lane = args.lanes.find((entry) => entry.id === status.laneId) ?? null; + const parentLane = status.parentLaneId ? args.lanes.find((entry) => entry.id === status.parentLaneId) ?? null : null; + return { + key: status.laneId, + laneId: status.laneId, + laneName: lane?.name ?? status.laneId, + parentLaneId: status.parentLaneId, + parentLaneName: parentLane?.name ?? status.parentLaneId, + state: status.state, + updatedAt: status.updatedAt, + conflictCount: status.conflictCount, + message: status.message, + stackDepth: lane?.stackDepth ?? null, + chainTrail: buildChainTrail(args.lanes, status.laneId), + } satisfies RebaseAttentionItem; + }) + .sort((a, b) => { + const severityDelta = severity(a.state) - severity(b.state); + if (severityDelta !== 0) return severityDelta; + const depthDelta = (b.stackDepth ?? -1) - (a.stackDepth ?? -1); + if (depthDelta !== 0) return depthDelta; + return Date.parse(b.updatedAt) - Date.parse(a.updatedAt); + }); +} + +export function formatRebaseAttentionSummary(item: RebaseAttentionItem): string { + const parentLabel = item.parentLaneName ?? "its ancestor"; + switch (item.state) { + case "rebaseConflict": + return `${item.laneName} hit conflicts while auto-rebasing from ${parentLabel}.`; + case "rebaseFailed": + return `Auto-rebase failed for ${item.laneName}; manual follow-up is required.`; + case "rebasePending": + return `${item.laneName} is waiting for ${parentLabel} to finish rebasing first.`; + default: + return `${item.laneName} has auto-rebase activity.`; + } +} + +export function findRebaseAttentionStatus( + autoRebaseStatuses: AutoRebaseLaneStatus[], + selectedItemId: string | null, +): AutoRebaseLaneStatus | null { + const normalizedId = String(selectedItemId ?? "").trim(); + if (!normalizedId) return null; + const laneId = normalizedId.startsWith("attention:") ? normalizedId.slice("attention:".length) : normalizedId; + return autoRebaseStatuses.find((status) => status.laneId === laneId) ?? null; +} diff --git a/apps/desktop/src/renderer/components/prs/shared/rebaseNeedUtils.test.ts b/apps/desktop/src/renderer/components/prs/shared/rebaseNeedUtils.test.ts index a2f7a5286..d9362fda1 100644 --- a/apps/desktop/src/renderer/components/prs/shared/rebaseNeedUtils.test.ts +++ b/apps/desktop/src/renderer/components/prs/shared/rebaseNeedUtils.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { rebaseNeedItemKey, findLaneBaseNeed, findMatchingRebaseNeed } from "./rebaseNeedUtils"; -import type { RebaseNeed } from "../../../../shared/types"; +import { + buildUpstreamRebaseChain, + findLaneBaseNeed, + findMatchingRebaseNeed, + formatUpstreamRebaseSummary, + rebaseNeedItemKey, + resolveRouteRebaseSelection, +} from "./rebaseNeedUtils"; +import type { LaneSummary, RebaseNeed } from "../../../../shared/types"; function makeNeed(overrides: Partial = {}): RebaseNeed { return { @@ -18,6 +25,34 @@ function makeNeed(overrides: Partial = {}): RebaseNeed { }; } +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: overrides.id ?? "lane-1", + name: overrides.name ?? "Feature Lane", + laneType: overrides.laneType ?? "worktree", + baseRef: overrides.baseRef ?? "refs/heads/main", + branchRef: overrides.branchRef ?? "refs/heads/feature", + worktreePath: overrides.worktreePath ?? "/tmp/feature", + parentLaneId: overrides.parentLaneId ?? null, + childCount: overrides.childCount ?? 0, + stackDepth: overrides.stackDepth ?? 0, + parentStatus: overrides.parentStatus ?? null, + isEditProtected: overrides.isEditProtected ?? false, + status: overrides.status ?? { + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: 0, + rebaseInProgress: false, + }, + color: overrides.color ?? null, + icon: overrides.icon ?? null, + tags: overrides.tags ?? [], + createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + describe("rebaseNeedItemKey", () => { it("produces a colon-delimited key for lane_base needs", () => { const need = makeNeed({ laneId: "lane-1", kind: "lane_base", prId: null, baseBranch: "main" }); @@ -211,3 +246,141 @@ describe("findMatchingRebaseNeed", () => { expect(result).toBeNull(); }); }); + +describe("resolveRouteRebaseSelection", () => { + it("keeps an exact rebase item key unchanged", () => { + const need = makeNeed({ laneId: "lane-1", kind: "lane_base", baseBranch: "main" }); + const itemKey = rebaseNeedItemKey(need); + + expect(resolveRouteRebaseSelection({ rebaseNeeds: [need], routeItemId: itemKey })).toBe(itemKey); + }); + + it("resolves a raw lane id to the lane-base need key when available", () => { + const laneBaseNeed = makeNeed({ laneId: "lane-1", kind: "lane_base", baseBranch: "main" }); + const prTargetNeed = makeNeed({ laneId: "lane-1", kind: "pr_target", prId: "pr-1", baseBranch: "develop" }); + + expect(resolveRouteRebaseSelection({ rebaseNeeds: [prTargetNeed, laneBaseNeed], routeItemId: "lane-1" })).toBe( + rebaseNeedItemKey(laneBaseNeed), + ); + }); + + it("falls back to another matching rebase need when no lane-base need exists", () => { + const prTargetNeed = makeNeed({ laneId: "lane-1", kind: "pr_target", prId: "pr-1", baseBranch: "develop" }); + + expect(resolveRouteRebaseSelection({ rebaseNeeds: [prTargetNeed], routeItemId: "lane-1" })).toBe( + rebaseNeedItemKey(prTargetNeed), + ); + }); + + it("preserves the raw route item id when rebase needs have not loaded yet", () => { + expect(resolveRouteRebaseSelection({ rebaseNeeds: [], routeItemId: "lane-1" })).toBe("lane-1"); + }); + + it("returns null for empty route ids", () => { + expect(resolveRouteRebaseSelection({ rebaseNeeds: [], routeItemId: " " })).toBeNull(); + }); +}); + +describe("buildUpstreamRebaseChain", () => { + it("surfaces the immediate parent's direct rebase need for a child lane", () => { + const lanes = [ + makeLane({ id: "root", name: "main" }), + makeLane({ id: "parent", name: "Parent Lane", parentLaneId: null, stackDepth: 1 }), + makeLane({ id: "child", name: "Child Lane", parentLaneId: "parent", stackDepth: 2 }), + ]; + const rebaseNeeds = [ + makeNeed({ laneId: "parent", laneName: "Parent Lane", baseBranch: "main", behindBy: 7 }), + makeNeed({ laneId: "child", laneName: "Child Lane", baseBranch: "parent", behindBy: 2 }), + ]; + + expect(buildUpstreamRebaseChain({ laneId: "child", lanes, rebaseNeeds })).toEqual([ + { + laneId: "parent", + laneName: "Parent Lane", + kind: "lane_base", + baseBranch: "main", + behindBy: 7, + conflictPredicted: false, + }, + ]); + }); + + it("walks the full ancestor chain without duplicating the selected lane", () => { + const lanes = [ + makeLane({ id: "grand", name: "Grand Lane", stackDepth: 1 }), + makeLane({ id: "parent", name: "Parent Lane", parentLaneId: "grand", stackDepth: 2 }), + makeLane({ id: "child", name: "Child Lane", parentLaneId: "parent", stackDepth: 3 }), + ]; + const rebaseNeeds = [ + makeNeed({ laneId: "grand", laneName: "Grand Lane", baseBranch: "main", behindBy: 7 }), + makeNeed({ laneId: "parent", laneName: "Parent Lane", baseBranch: "grand", behindBy: 2 }), + makeNeed({ laneId: "child", laneName: "Child Lane", baseBranch: "parent", behindBy: 1 }), + ]; + + expect(buildUpstreamRebaseChain({ laneId: "child", lanes, rebaseNeeds })).toEqual([ + { + laneId: "parent", + laneName: "Parent Lane", + kind: "lane_base", + baseBranch: "grand", + behindBy: 2, + conflictPredicted: false, + }, + { + laneId: "grand", + laneName: "Grand Lane", + kind: "lane_base", + baseBranch: "main", + behindBy: 7, + conflictPredicted: false, + }, + ]); + }); + + it("returns an empty chain for top-level lanes", () => { + const lanes = [makeLane({ id: "root", name: "Root Lane" })]; + const rebaseNeeds = [makeNeed({ laneId: "root", laneName: "Root Lane", baseBranch: "main", behindBy: 3 })]; + + expect(buildUpstreamRebaseChain({ laneId: "root", lanes, rebaseNeeds })).toEqual([]); + }); +}); + +describe("formatUpstreamRebaseSummary", () => { + it("returns null when there is no upstream drift", () => { + expect(formatUpstreamRebaseSummary([])).toBeNull(); + }); + + it("summarizes a single ancestor directly", () => { + expect(formatUpstreamRebaseSummary([ + { + laneId: "parent", + laneName: "Parent Lane", + kind: "lane_base", + baseBranch: "main", + behindBy: 7, + conflictPredicted: false, + }, + ])).toBe("Parent Lane is 7 behind main"); + }); + + it("summarizes multiple ancestors using the terminal upstream target", () => { + expect(formatUpstreamRebaseSummary([ + { + laneId: "parent", + laneName: "Parent Lane", + kind: "lane_base", + baseBranch: "grand", + behindBy: 2, + conflictPredicted: false, + }, + { + laneId: "grand", + laneName: "Grand Lane", + kind: "lane_base", + baseBranch: "main", + behindBy: 7, + conflictPredicted: false, + }, + ])).toBe("2 ancestors pending; Grand Lane is 7 behind main"); + }); +}); diff --git a/apps/desktop/src/renderer/components/prs/shared/rebaseNeedUtils.ts b/apps/desktop/src/renderer/components/prs/shared/rebaseNeedUtils.ts index 083a04304..d4a032a21 100644 --- a/apps/desktop/src/renderer/components/prs/shared/rebaseNeedUtils.ts +++ b/apps/desktop/src/renderer/components/prs/shared/rebaseNeedUtils.ts @@ -1,9 +1,37 @@ -import type { RebaseNeed } from "../../../../shared/types"; +import type { LaneSummary, RebaseNeed } from "../../../../shared/types"; + +export type UpstreamRebaseNeed = { + laneId: string; + laneName: string; + kind: RebaseNeed["kind"]; + baseBranch: string; + behindBy: number; + conflictPredicted: boolean; +}; export function rebaseNeedItemKey(need: RebaseNeed): string { return `${need.laneId}:${need.kind}:${need.prId ?? "base"}:${need.baseBranch}`; } +export function resolveRouteRebaseSelection(args: { + rebaseNeeds: RebaseNeed[] | null | undefined; + routeItemId?: string | null; +}): string | null { + const routeItemId = (args.routeItemId ?? "").trim(); + if (!routeItemId) return null; + + const rebaseNeeds = args.rebaseNeeds ?? []; + if (rebaseNeeds.some((need) => rebaseNeedItemKey(need) === routeItemId)) { + return routeItemId; + } + + const laneBaseNeed = findLaneBaseNeed(rebaseNeeds, routeItemId); + if (laneBaseNeed) return rebaseNeedItemKey(laneBaseNeed); + + const matchingNeed = findMatchingRebaseNeed({ rebaseNeeds, laneId: routeItemId }); + return matchingNeed ? rebaseNeedItemKey(matchingNeed) : routeItemId; +} + export function findLaneBaseNeed(rebaseNeeds: RebaseNeed[], laneId: string): RebaseNeed | null { return rebaseNeeds.find((need) => need.laneId === laneId && need.kind === "lane_base") ?? null; } @@ -32,3 +60,50 @@ export function findMatchingRebaseNeed(args: { return rebaseNeeds.find((need) => need.laneId === args.laneId) ?? null; } + +export function buildUpstreamRebaseChain(args: { + laneId: string; + lanes: LaneSummary[] | null | undefined; + rebaseNeeds: RebaseNeed[] | null | undefined; +}): UpstreamRebaseNeed[] { + const lanes = args.lanes ?? []; + const rebaseNeeds = args.rebaseNeeds ?? []; + const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const entries: UpstreamRebaseNeed[] = []; + const visited = new Set([args.laneId]); + + let currentLane = laneById.get(args.laneId) ?? null; + let parentLaneId = currentLane?.parentLaneId ?? null; + + while (parentLaneId && !visited.has(parentLaneId)) { + visited.add(parentLaneId); + currentLane = laneById.get(parentLaneId) ?? null; + if (!currentLane) break; + + const directNeed = findLaneBaseNeed(rebaseNeeds, currentLane.id) + ?? findMatchingRebaseNeed({ rebaseNeeds, laneId: currentLane.id }); + if (directNeed && directNeed.behindBy > 0) { + entries.push({ + laneId: currentLane.id, + laneName: currentLane.name, + kind: directNeed.kind, + baseBranch: directNeed.baseBranch, + behindBy: directNeed.behindBy, + conflictPredicted: directNeed.conflictPredicted, + }); + } + + parentLaneId = currentLane.parentLaneId; + } + + return entries; +} + +export function formatUpstreamRebaseSummary(entries: UpstreamRebaseNeed[]): string | null { + if (!entries.length) return null; + + const terminalEntry = entries[entries.length - 1]; + const suffix = `${terminalEntry.laneName} is ${terminalEntry.behindBy} behind ${terminalEntry.baseBranch}`; + if (entries.length === 1) return suffix; + return `${entries.length} ancestors pending; ${suffix}`; +} diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx index 8f96d3c05..df0557a5a 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.test.tsx @@ -4,7 +4,7 @@ import React from "react"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AutoRebaseLaneStatus, RebaseNeed } from "../../../../shared/types"; +import type { AutoRebaseLaneStatus, PrConvergenceState, PrConvergenceStatePatch, PrWithConflicts, RebaseNeed } from "../../../../shared/types"; import { PrsProvider, usePrs } from "./PrsContext"; const originalAde = globalThis.window.ade; @@ -103,3 +103,428 @@ describe("PrsContext refresh", () => { }); }); }); + +// --------------------------------------------------------------------------- +// Convergence state management +// --------------------------------------------------------------------------- + +function makeFakeConvergenceState(prId: string, overrides?: Partial): PrConvergenceState { + const now = new Date().toISOString(); + return { + prId, + autoConvergeEnabled: false, + status: "idle", + pollerStatus: "idle", + currentRound: 0, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function makeFakePr(id: string): PrWithConflicts { + return { + id, + laneId: `lane-${id}`, + projectId: "proj-1", + repoOwner: "test-owner", + repoName: "test-repo", + githubPrNumber: 1, + githubUrl: `https://github.com/test-owner/test-repo/pull/1`, + githubNodeId: null, + title: `PR ${id}`, + state: "open", + baseBranch: "main", + headBranch: `feature-${id}`, + checksStatus: "passing", + reviewStatus: "approved", + additions: 10, + deletions: 2, + lastSyncedAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + conflictAnalysis: null, + }; +} + +/** Harness that exposes convergence-related methods and state */ +function ConvergenceHarness() { + const { + loadConvergenceState, + saveConvergenceState, + resetConvergenceState, + convergenceStatesByPrId, + loading, + } = usePrs(); + + const resultRef = React.useRef(null); + + return ( +
+
{loading ? "loading" : "idle"}
+
{Object.keys(convergenceStatesByPrId).sort().join(",")}
+
{resultRef.current ? JSON.stringify(resultRef.current) : "none"}
+ + + + + +
+ ); +} + +describe("PrsContext convergence state", () => { + let convergenceGetMock: ReturnType; + let convergenceSaveMock: ReturnType; + let convergenceDeleteMock: ReturnType; + + beforeEach(() => { + convergenceGetMock = vi.fn().mockImplementation(() => + Promise.resolve(makeFakeConvergenceState("pr-1")), + ); + convergenceSaveMock = vi.fn().mockImplementation(() => + Promise.resolve(makeFakeConvergenceState("pr-1", { autoConvergeEnabled: true })), + ); + convergenceDeleteMock = vi.fn().mockResolvedValue(undefined); + + globalThis.window.ade = { + prs: { + refresh: vi.fn().mockResolvedValue(undefined), + listWithConflicts: vi.fn().mockResolvedValue([makeFakePr("pr-1")]), + listQueueStates: vi.fn().mockResolvedValue([]), + onEvent: vi.fn(() => () => {}), + convergenceStateGet: convergenceGetMock, + convergenceStateSave: convergenceSaveMock, + convergenceStateDelete: convergenceDeleteMock, + }, + lanes: { + list: vi.fn().mockResolvedValue([]), + listAutoRebaseStatuses: vi.fn().mockResolvedValue([]), + onAutoRebaseEvent: vi.fn(() => () => {}), + }, + rebase: { + scanNeeds: vi.fn().mockResolvedValue([]), + onEvent: vi.fn(() => () => {}), + }, + } as any; + }); + + afterEach(() => { + cleanup(); + globalThis.window.ade = originalAde; + }); + + it("loadConvergenceState calls IPC when not cached and stores result", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + // Wait for initial load + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + + // Click load — should call IPC + await user.click(screen.getByTestId("load")); + + await waitFor(() => { + expect(convergenceGetMock).toHaveBeenCalledWith("pr-1"); + }); + + // State should now be cached + await waitFor(() => { + expect(screen.getByTestId("cached-keys").textContent).toBe("pr-1"); + }); + }); + + it("loadConvergenceState returns cached state without calling IPC when already loaded", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + + // First load — populates cache via IPC + await user.click(screen.getByTestId("load")); + await waitFor(() => { + expect(convergenceGetMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("cached-keys").textContent).toBe("pr-1"); + }); + + // Second load — should use cache, no new IPC call + await user.click(screen.getByTestId("load")); + + // Give React a tick to process + await waitFor(() => { + expect(screen.getByTestId("cached-keys").textContent).toBe("pr-1"); + }); + + // IPC should still have been called only once + expect(convergenceGetMock).toHaveBeenCalledTimes(1); + }); + + it("loadConvergenceState with force: true always calls IPC even when cached", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + + // First load — populates cache + await user.click(screen.getByTestId("load")); + await waitFor(() => { + expect(convergenceGetMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("cached-keys").textContent).toBe("pr-1"); + }); + + // Force load — should call IPC again + await user.click(screen.getByTestId("load-force")); + await waitFor(() => { + expect(convergenceGetMock).toHaveBeenCalledTimes(2); + }); + }); + + it("saveConvergenceState calls IPC and updates cache", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + + // Save — calls IPC and caches + await user.click(screen.getByTestId("save")); + + await waitFor(() => { + expect(convergenceSaveMock).toHaveBeenCalledWith("pr-1", { autoConvergeEnabled: true }); + }); + + // State should now be cached + await waitFor(() => { + expect(screen.getByTestId("cached-keys").textContent).toBe("pr-1"); + }); + }); + + it("uses the updated ref cache for a back-to-back save then load", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + + await user.click(screen.getByTestId("save-then-load")); + + await waitFor(() => { + expect(convergenceSaveMock).toHaveBeenCalledWith("pr-1", { autoConvergeEnabled: true }); + }); + + expect(convergenceGetMock).not.toHaveBeenCalled(); + }); + + it("resetConvergenceState calls IPC delete and removes from cache", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + + // First load to populate cache + await user.click(screen.getByTestId("load")); + await waitFor(() => { + expect(screen.getByTestId("cached-keys").textContent).toBe("pr-1"); + }); + + // Reset — calls IPC delete and removes from cache + await user.click(screen.getByTestId("reset")); + + await waitFor(() => { + expect(convergenceDeleteMock).toHaveBeenCalledWith("pr-1"); + }); + + await waitFor(() => { + expect(screen.getByTestId("cached-keys").textContent).toBe(""); + }); + }); + + it("convergence states are pruned when PR list changes", async () => { + const pr1 = makeFakePr("pr-1"); + const pr2 = makeFakePr("pr-2"); + + // Use a mutable variable so we can switch the return value after init + let activePrs: PrWithConflicts[] = [pr1, pr2]; + const listMock = vi.fn().mockImplementation(() => Promise.resolve(activePrs)); + + globalThis.window.ade = { + prs: { + refresh: vi.fn().mockResolvedValue(undefined), + listWithConflicts: listMock, + listQueueStates: vi.fn().mockResolvedValue([]), + onEvent: vi.fn(() => () => {}), + convergenceStateGet: vi.fn().mockImplementation((prId: string) => + Promise.resolve(makeFakeConvergenceState(prId)), + ), + convergenceStateSave: vi.fn().mockImplementation((prId: string, partial: PrConvergenceStatePatch) => + Promise.resolve(makeFakeConvergenceState(prId, partial)), + ), + convergenceStateDelete: vi.fn().mockResolvedValue(undefined), + }, + lanes: { + list: vi.fn().mockResolvedValue([]), + listAutoRebaseStatuses: vi.fn().mockResolvedValue([]), + onAutoRebaseEvent: vi.fn(() => () => {}), + }, + rebase: { + scanNeeds: vi.fn().mockResolvedValue([]), + onEvent: vi.fn(() => () => {}), + }, + } as any; + + function PruneHarness() { + const { + loadConvergenceState, + convergenceStatesByPrId, + loading, + refresh, + } = usePrs(); + + return ( +
+
{loading ? "loading" : "idle"}
+
{Object.keys(convergenceStatesByPrId).sort().join(",")}
+ + + +
+ ); + } + + const user = userEvent.setup(); + render( + + + , + ); + + // Wait for initial load + await waitFor(() => { + expect(screen.getByTestId("loading").textContent).toBe("idle"); + }); + + // Load convergence state for both PRs + await user.click(screen.getByTestId("load-pr1")); + await user.click(screen.getByTestId("load-pr2")); + + await waitFor(() => { + expect(screen.getByTestId("cached-keys").textContent).toBe("pr-1,pr-2"); + }); + + // Switch the PR list to only include pr-1, then refresh + activePrs = [pr1]; + await user.click(screen.getByTestId("refresh")); + + // After refresh, pr-2 should be pruned from convergence cache + await waitFor(() => { + expect(screen.getByTestId("cached-keys").textContent).toBe("pr-1"); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index cf011d6b1..aaa6f04cd 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -8,6 +8,7 @@ import React, { } from "react"; import type { AiPermissionMode, + PrConvergenceState, PrAiResolutionContext, PrAiResolutionSessionInfo, PrWithConflicts, @@ -24,6 +25,7 @@ import type { LaneSummary, AutoRebaseLaneStatus, AutoRebaseEventPayload, + PrConvergenceStatePatch, } from "../../../../shared/types"; import { buildPrAiResolutionContextKey } from "../../../../shared/types"; import { getModelById } from "../../../../shared/modelRegistry"; @@ -68,6 +70,9 @@ type PrsState = { // Inline terminal inlineTerminal: InlineTerminalState; + // Persisted convergence runtime cache + convergenceStatesByPrId: Record; + // Resolver preferences resolverModel: string; resolverReasoningLevel: string; @@ -87,12 +92,16 @@ type PrsContextValue = PrsState & { upsertResolverSession: (session: PrAiResolutionSessionInfo) => void; clearResolverSession: (context: PrAiResolutionContext) => void; setInlineTerminal: (terminal: InlineTerminalState) => void; + loadConvergenceState: (prId: string, options?: { force?: boolean }) => Promise; + saveConvergenceState: (prId: string, state: PrConvergenceStatePatch) => Promise; + resetConvergenceState: (prId: string) => Promise; refresh: () => Promise; }; const PrsContext = createContext(null); const LS_MODEL_KEY = "ade:prs:resolverModel"; +const LS_REASONING_KEY = "ade:prs:resolverReasoningLevel"; const LS_PERMISSION_KEY = "ade:prs:resolverPermissions"; type ResolverPermissionPreferences = { @@ -139,6 +148,16 @@ function readPersistedModel(): string { return "anthropic/claude-sonnet-4-6"; } +function readPersistedReasoningLevel(): string { + try { + const value = localStorage.getItem(LS_REASONING_KEY); + if (value && value.trim().length > 0) return value.trim(); + } catch { + /* ignore */ + } + return "medium"; +} + function readInitialTab(): PrTab { try { const params = new URLSearchParams(window.location.search); @@ -148,6 +167,20 @@ function readInitialTab(): PrTab { return "normal"; } +function requirePrId(prId: string): string { + const normalized = String(prId ?? "").trim(); + if (!normalized) throw new Error("PR id is required."); + return normalized; +} + +/** Remove entries from a keyed record whose key is not in the allowed set. */ +function pruneByAllowedIds(record: Record, allowedIds: Set): Record { + const next = Object.fromEntries( + Object.entries(record).filter(([id]) => allowedIds.has(id)), + ) as Record; + return jsonEqual(record, next) ? record : next; +} + /** Shallow-compare two JSON-serializable values to avoid unnecessary re-renders. */ function jsonEqual(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); @@ -207,9 +240,16 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { // Inline terminal const [inlineTerminal, setInlineTerminal] = useState(null); + // Persisted convergence runtime cache + const [convergenceStatesByPrId, setConvergenceStatesByPrId] = useState>({}); + const convergenceStatesByPrIdRef = React.useRef>({}); + React.useEffect(() => { + convergenceStatesByPrIdRef.current = convergenceStatesByPrId; + }, [convergenceStatesByPrId]); + // Resolver preferences const [resolverModel, setResolverModelRaw] = useState(readPersistedModel); - const [resolverReasoningLevel, setResolverReasoningLevel] = useState("medium"); + const [resolverReasoningLevel, setResolverReasoningLevelRaw] = useState(readPersistedReasoningLevel); const [resolverPermissions, setResolverPermissions] = useState(readPersistedResolverPermissions); const [resolverSessionsByContextKey, setResolverSessionsByContextKey] = useState>({}); @@ -235,6 +275,15 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { }); }, [resolverModel]); + const setResolverReasoningLevel = useCallback((level: string) => { + setResolverReasoningLevelRaw(level); + try { + localStorage.setItem(LS_REASONING_KEY, level); + } catch { + /* ignore */ + } + }, []); + const upsertResolverSession = useCallback((session: PrAiResolutionSessionInfo) => { setResolverSessionsByContextKey((prev) => ({ ...prev, [session.contextKey]: session })); }, []); @@ -249,6 +298,57 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { }); }, []); + const storeConvergenceState = useCallback((state: PrConvergenceState): PrConvergenceState => { + // Guard against late IPC responses for PRs that have been pruned from the list. + // Only apply the guard after the initial load has completed — before that the PR + // list is empty and states should still be cached so explicit load/save calls work. + // Using initialLoadDone (rather than prsRef.current.length > 0) ensures that once + // the list is known, stale responses for unknown PR ids are always rejected — even + // when the list becomes empty after pruning. + if (initialLoadDone.current && !prsRef.current.some((pr) => pr.id === state.prId)) { + return state; + } + setConvergenceStatesByPrId((prev) => { + if (jsonEqual(prev[state.prId], state)) return prev; + const next = { ...prev, [state.prId]: state }; + convergenceStatesByPrIdRef.current = next; + return next; + }); + return state; + }, []); + + const loadConvergenceState = useCallback(async (prId: string, options?: { force?: boolean }): Promise => { + const normalizedPrId = requirePrId(prId); + if (!options?.force) { + const cached = convergenceStatesByPrIdRef.current[normalizedPrId]; + if (cached) return cached; + } + const runtime = await window.ade.prs.convergenceStateGet(normalizedPrId); + return storeConvergenceState(runtime); + }, [storeConvergenceState]); + + const saveConvergenceState = useCallback(async (prId: string, state: PrConvergenceStatePatch): Promise => { + const normalizedPrId = requirePrId(prId); + const runtime = await window.ade.prs.convergenceStateSave(normalizedPrId, state); + return storeConvergenceState(runtime); + }, [storeConvergenceState]); + + const resetConvergenceState = useCallback(async (prId: string): Promise => { + const normalizedPrId = String(prId ?? "").trim(); + if (!normalizedPrId) return; + await window.ade.prs.convergenceStateDelete(normalizedPrId); + // Update the mutable ref synchronously so callers that read it + // immediately after reset don't see stale data. + const { [normalizedPrId]: _, ...rest } = convergenceStatesByPrIdRef.current; + convergenceStatesByPrIdRef.current = rest; + setConvergenceStatesByPrId((prev) => { + if (!(normalizedPrId in prev)) return prev; + const next = { ...prev }; + delete next[normalizedPrId]; + return next; + }); + }, []); + // Concurrency guard for refresh const refreshInFlight = React.useRef(false); const refreshPending = React.useRef(false); @@ -362,13 +462,9 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { return prev; }); - setMergeContextByPrId((prev) => { - const allowed = new Set(prList.map((pr) => pr.id)); - const next = Object.fromEntries( - Object.entries(prev).filter(([prId]) => allowed.has(prId)) - ) as Record; - return jsonEqual(prev, next) ? prev : next; - }); + const allowedPrIds = new Set(prList.map((pr) => pr.id)); + setMergeContextByPrId((prev) => pruneByAllowedIds(prev, allowedPrIds)); + setConvergenceStatesByPrId((prev) => pruneByAllowedIds(prev, allowedPrIds)); if (changedPrIds.length > 0) { void refreshMergeContexts(changedPrIds); @@ -598,6 +694,18 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { prsRef.current = next; setPrs((prev) => (jsonEqual(prev, next) ? prev : next)); + const allowedPrIds = new Set(next.map((pr) => pr.id)); + setConvergenceStatesByPrId((prev) => pruneByAllowedIds(prev, allowedPrIds)); + + // Clear selection if the active PR was removed (mirrors refresh() guard). + const activePrIdForPrune = selectedPrIdRef.current; + if (activePrIdForPrune && !allowedPrIds.has(activePrIdForPrune)) { + setDetailStatus(null); + setDetailChecks([]); + setDetailReviews([]); + setDetailComments([]); + setSelectedPrId(null); + } if (changedPrIds.length > 0) { void refreshMergeContexts(changedPrIds); @@ -693,6 +801,7 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { autoRebaseStatuses, queueStates, inlineTerminal, + convergenceStatesByPrId, resolverModel, resolverReasoningLevel, resolverPermissionMode: resolverPermissions[resolvePermissionFamilyForModel(resolverModel)], @@ -708,13 +817,16 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { upsertResolverSession, clearResolverSession, setInlineTerminal, + loadConvergenceState, + saveConvergenceState, + resetConvergenceState, refresh, }), // Note: setActiveTab, setSelectedPrId, setSelectedQueueGroupId, setSelectedRebaseItemId, - // setMergeMethod, setResolverReasoningLevel, and setInlineTerminal are intentionally - // excluded from this dependency array because they are useState setters which are - // guaranteed to be referentially stable across re-renders per the React useState contract. - // setResolverModel is included because it's a useCallback wrapper (not a raw setter). + // setMergeMethod, and setInlineTerminal are intentionally excluded from this dependency + // array because they are useState setters which are guaranteed to be referentially stable + // across re-renders per the React useState contract. Resolver preference setters are + // included because they are useCallback wrappers (not raw setters). [ activeTab, prs, @@ -735,14 +847,19 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { autoRebaseStatuses, queueStates, inlineTerminal, + convergenceStatesByPrId, resolverModel, resolverReasoningLevel, resolverPermissions, resolverSessionsByContextKey, setResolverModel, + setResolverReasoningLevel, setResolverPermissionMode, upsertResolverSession, clearResolverSession, + loadConvergenceState, + saveConvergenceState, + resetConvergenceState, refresh, ], ); diff --git a/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx index b99c392cf..8bba72c09 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/RebaseTab.tsx @@ -1,13 +1,23 @@ import React from "react"; import { ArrowsDownUp, Clock, CheckCircle, Warning, Sparkle, Eye, XCircle, GitCommit, FileText, CaretDown, CaretRight, ArrowRight, CircleNotch } from "@phosphor-icons/react"; -import type { AiPermissionMode, GitCommitSummary, LaneSummary, RebaseNeed, RebaseRun, RebaseScope } from "../../../../shared/types"; +import type { AiPermissionMode, AutoRebaseLaneStatus, GitCommitSummary, LaneSummary, RebaseNeed, RebaseRun, RebaseScope } from "../../../../shared/types"; import { Button } from "../../ui/Button"; import { EmptyState } from "../../ui/EmptyState"; import { cn } from "../../ui/cn"; import { PaneTilingLayout, type PaneConfig } from "../../ui/PaneTilingLayout"; import { UrgencyGroup } from "../shared/UrgencyGroup"; import { branchNameFromRef } from "../shared/laneBranchTargets"; -import { rebaseNeedItemKey } from "../shared/rebaseNeedUtils"; +import { + buildUpstreamRebaseChain, + findMatchingRebaseNeed, + formatUpstreamRebaseSummary, + rebaseNeedItemKey, +} from "../shared/rebaseNeedUtils"; +import { + buildRebaseAttentionItems, + findRebaseAttentionStatus, + formatRebaseAttentionSummary, +} from "../shared/rebaseAttentionUtils"; import { StatusDot } from "../shared/StatusDot"; import { PR_TAB_TILING_TREE } from "../shared/tilingConstants"; import { PrResolverLaunchControls } from "../shared/PrResolverLaunchControls"; @@ -15,6 +25,7 @@ import { formatTimeAgo } from "../shared/prFormatters"; type RebaseTabProps = { rebaseNeeds: RebaseNeed[]; + attentionStatuses: AutoRebaseLaneStatus[]; lanes: LaneSummary[]; selectedItemId: string | null; onSelectItem: (id: string | null) => void; @@ -27,7 +38,8 @@ type RebaseTabProps = { onNavigate: (path: string) => void; }; -type RebaseSectionKey = "lane_base" | "pr_target"; +type RebaseSectionKey = "lane_base" | "pr_target" | "stack_attention"; +type NeedSectionKey = Exclude; function rebaseRunKey(args: { laneId: string; baseBranch?: string | null }): string { return `${args.laneId}:${branchNameFromRef(args.baseBranch)}`; @@ -53,8 +65,51 @@ const S = { info: "#3B82F6", } as const; +function ErrorBanner({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} + +function attentionStateVisuals(state: string): { label: string; color: string } { + switch (state) { + case "rebaseConflict": return { label: "CONFLICT", color: S.error }; + case "rebaseFailed": return { label: "FAILED", color: S.warning }; + default: return { label: "PENDING", color: S.info }; + } +} + +function attentionStatusBadgeVisuals(state: string): { label: string; color: string; bgColor: string; borderColor: string } { + switch (state) { + case "rebaseConflict": + return { label: "STACK CONFLICT", color: S.error, bgColor: "#EF444418", borderColor: "1px solid #EF444430" }; + case "rebaseFailed": + return { label: "STACK FAILED", color: S.warning, bgColor: "#F59E0B18", borderColor: "1px solid #F59E0B30" }; + case "rebasePending": + return { label: "STACK PENDING", color: S.info, bgColor: "#3B82F618", borderColor: "1px solid #3B82F630" }; + default: + return { label: "AUTO REBASED", color: S.info, bgColor: "#3B82F618", borderColor: "1px solid #3B82F630" }; + } +} + export function RebaseTab({ rebaseNeeds, + attentionStatuses, lanes, selectedItemId, onSelectItem, @@ -97,10 +152,11 @@ export function RebaseTab({ const [collapsed, setCollapsed] = React.useState>({ lane_base: false, pr_target: false, + stack_attention: false, }); const grouped = React.useMemo(() => { - const groups: Record = { + const groups: Record = { lane_base: [], pr_target: [], }; @@ -114,8 +170,44 @@ export function RebaseTab({ const selectedNeed = React.useMemo(() => { if (!selectedItemId) return null; - return rebaseNeeds.find((need) => rebaseNeedItemKey(need) === selectedItemId) ?? null; + return rebaseNeeds.find((need) => rebaseNeedItemKey(need) === selectedItemId) + ?? findMatchingRebaseNeed({ rebaseNeeds, laneId: selectedItemId }); }, [rebaseNeeds, selectedItemId]); + const attentionItems = React.useMemo( + () => buildRebaseAttentionItems({ + autoRebaseStatuses: attentionStatuses, + lanes, + visibleRebaseNeeds: rebaseNeeds, + view: "active", + }), + [attentionStatuses, lanes, rebaseNeeds], + ); + const selectedAttentionStatus = React.useMemo( + () => (selectedNeed ? null : findRebaseAttentionStatus(attentionStatuses, selectedItemId)), + [attentionStatuses, selectedItemId, selectedNeed], + ); + const selectedAttentionItem = React.useMemo( + () => { + if (selectedNeed || !selectedAttentionStatus) return null; + return attentionItems.find((item) => item.laneId === selectedAttentionStatus.laneId) ?? null; + }, + [attentionItems, selectedAttentionStatus, selectedNeed], + ); + const upstreamChainByLaneId = React.useMemo(() => { + const next = new Map>(); + for (const need of rebaseNeeds) { + next.set(need.laneId, buildUpstreamRebaseChain({ + laneId: need.laneId, + lanes, + rebaseNeeds, + })); + } + return next; + }, [lanes, rebaseNeeds]); + const selectedNeedUpstreamChain = React.useMemo( + () => (selectedNeed ? upstreamChainByLaneId.get(selectedNeed.laneId) ?? [] : []), + [selectedNeed, upstreamChainByLaneId], + ); const selectedNeedRunKey = React.useMemo( () => (selectedNeed ? rebaseRunKey({ laneId: selectedNeed.laneId, baseBranch: selectedNeed.baseBranch }) : null), @@ -123,8 +215,12 @@ export function RebaseTab({ ); const selectedLane = React.useMemo( - () => (selectedNeed ? laneById.get(selectedNeed.laneId) ?? null : null), - [selectedNeed, laneById], + () => { + if (selectedNeed) return laneById.get(selectedNeed.laneId) ?? null; + if (selectedAttentionItem) return laneById.get(selectedAttentionItem.laneId) ?? null; + return null; + }, + [laneById, selectedAttentionItem, selectedNeed], ); const hasChildren = (selectedLane?.childCount ?? 0) > 0; @@ -156,11 +252,15 @@ export function RebaseTab({ // Auto-select first item in highest-urgency group React.useEffect(() => { - if (rebaseNeeds.length === 0 && selectedItemId === null) return; - if (selectedItemId && rebaseNeeds.some((need) => rebaseNeedItemKey(need) === selectedItemId)) return; + if (rebaseNeeds.length === 0 && attentionItems.length === 0 && selectedItemId === null) return; + if (selectedNeed || selectedAttentionItem) return; const first = grouped.lane_base[0] ?? grouped.pr_target[0]; - onSelectItem(first ? rebaseNeedItemKey(first) : null); - }, [rebaseNeeds, selectedItemId, grouped, onSelectItem]); + if (first) { + onSelectItem(rebaseNeedItemKey(first)); + return; + } + onSelectItem(attentionItems[0]?.laneId ?? null); + }, [attentionItems, grouped, onSelectItem, rebaseNeeds.length, selectedAttentionItem, selectedItemId, selectedNeed]); React.useEffect(() => { setRebaseError(null); @@ -422,6 +522,7 @@ export function RebaseTab({ const isSelected = itemKey === selectedItemId; const laneName = laneById.get(need.laneId)?.name ?? need.laneId; const kindLabel = need.kind === "pr_target" ? "PR TARGET" : "LANE BASE"; + const upstreamSummary = formatUpstreamRebaseSummary(upstreamChainByLaneId.get(need.laneId) ?? []); return (
+ {upstreamSummary ? ( +
+ UPSTREAM · {upstreamSummary} +
+ ) : null}
@@ -494,18 +600,67 @@ export function RebaseTab({ ); }; - const urgencyGroups: Array<{ key: RebaseSectionKey; title: string; color: string; icon: typeof Warning }> = [ + const renderAttentionItem = (item: (typeof attentionItems)[number]) => { + const isSelected = !selectedNeed && selectedAttentionItem?.laneId === item.laneId; + const lane = laneById.get(item.laneId) ?? null; + const { label: stateLabel, color: stateColor } = attentionStateVisuals(item.state); + + return ( + + ); + }; + + const urgencyGroups: Array<{ key: NeedSectionKey; title: string; color: string; icon: typeof Warning }> = [ { key: "lane_base", title: "Rebase Against Lane Base", color: S.info, icon: ArrowsDownUp }, { key: "pr_target", title: "Rebase Against PR Target", color: S.warning, icon: Warning }, ]; - const resolverTargetLaneId = React.useMemo(() => { - if (!selectedNeed) return null; - const baseBranch = branchNameFromRef(selectedNeed.baseBranch); - if (!baseBranch) return null; - return lanes.find((lane) => branchNameFromRef(lane.branchRef) === baseBranch)?.id ?? null; - }, [lanes, selectedNeed]); - const shouldRenderDriftPanel = !selectedNeedIsPrTarget || Boolean(driftSourceLaneId); const paneConfigs: Record = React.useMemo( @@ -534,11 +689,11 @@ export function RebaseTab({
- {rebaseNeeds.length === 0 ? ( + {rebaseNeeds.length === 0 && attentionItems.length === 0 ? (
) : ( @@ -559,6 +714,19 @@ export function RebaseTab({
))} + {attentionItems.length > 0 ? ( + setCollapsed((prev) => ({ ...prev, stack_attention: !prev.stack_attention }))} + > +
+ {attentionItems.map(renderAttentionItem)} +
+
+ ) : null}
)} @@ -567,7 +735,9 @@ export function RebaseTab({ detail: { title: selectedNeed ? `Rebase: ${laneById.get(selectedNeed.laneId)?.name ?? selectedNeed.laneId}` - : "Rebase Detail", + : selectedAttentionItem + ? `Stack attention: ${selectedAttentionItem.laneName}` + : "Rebase Detail", icon: Eye, bodyClassName: "overflow-auto", children: selectedNeed ? ( @@ -643,6 +813,86 @@ export function RebaseTab({ {shouldRenderDriftPanel ? ( <> + {selectedNeedUpstreamChain.length > 0 ? ( +
+
+ UPSTREAM CHAIN +
+
+ This lane still rebases directly onto {selectedNeed.baseBranch}. + The items below are ancestor lanes that are also still behind upstream targets. +
+
+ {selectedNeedUpstreamChain.map((entry) => ( +
+
+
+ {entry.laneName} +
+
+ {entry.kind === "pr_target" ? "PR TARGET" : "LANE BASE"} · {entry.baseBranch} +
+
+
+ + {entry.behindBy} BEHIND + + {entry.conflictPredicted ? ( + + CONFLICTS + + ) : null} +
+
+ ))} +
+
+ ) : null} + {/* ── Drift Analysis Card ── */}
{/* ── Error Banner ── */} - {rebaseError && ( -
- - {rebaseError} + {rebaseError && } + +
+ ) : selectedAttentionStatus ? ( +
+
+
+
+
+ {selectedLane?.name ?? selectedAttentionStatus.laneId} +
+
+ + stack attention + + {(() => { + const badge = attentionStatusBadgeVisuals(selectedAttentionStatus.state); + return ( + + {badge.label} + + ); + })()} +
+
- )} +
+ +
+
+ {selectedAttentionItem + ? formatRebaseAttentionSummary(selectedAttentionItem) + : selectedAttentionStatus.message ?? "This lane is waiting on another lane in the stack before it can continue rebasing cleanly."} +
+ {selectedAttentionItem ? ( +
+ Chain: {selectedAttentionItem.chainTrail.join(" > ")} +
+ ) : null} +
+ {selectedLane?.parentLaneId + ? <>Parent lane: {laneById.get(selectedLane.parentLaneId)?.name ?? selectedLane.parentLaneId} + : "This lane does not currently track a non-primary parent lane."} +
+
+ Updated {formatTimeAgo(selectedAttentionStatus.updatedAt)} +
+
+ {selectedAttentionStatus.state === "rebasePending" + ? "Resolve or rebase the upstream lane in this stack first. ADE will surface a direct rebase target for this lane once its immediate parent actually moves." + : "This attention state came from the stack rebase monitor. If the lane still needs a direct rebase after refresh, it will appear above as a standard rebase item with full actions."} +
+
+ {rebaseError ? : null}
) : (
!need.dismissedAt && !(need.deferredUntil && new Date(need.deferredUntil) > new Date()) && need.behindBy > 0), history: rebaseNeeds.filter((need) => need.dismissedAt || (need.deferredUntil && new Date(need.deferredUntil) > new Date()) || need.behindBy === 0), }), [rebaseNeeds]); + const rebaseAttentionByView = React.useMemo(() => ({ + active: filterRebaseAttentionStatuses({ + autoRebaseStatuses, + visibleRebaseNeeds: rebaseByView.active, + view: "active", + }), + history: [] as typeof autoRebaseStatuses, + }), [autoRebaseStatuses, rebaseByView.active]); React.useEffect(() => { if (activeCategory !== "rebase" || !selectedRebaseItemId) return; @@ -684,16 +694,26 @@ export function WorkflowsTab({ if (view !== "active") setView("active"); return; } + const activeAttentionLaneIds = new Set(rebaseAttentionByView.active.map((status) => status.laneId)); + if (activeAttentionLaneIds.has(selectedRebaseItemId)) { + if (view !== "active") setView("active"); + return; + } const historyKeys = new Set(rebaseByView.history.map(rebaseNeedItemKey)); if (historyKeys.has(selectedRebaseItemId) && view !== "history") { setView("history"); + return; + } + const historyAttentionLaneIds = new Set(rebaseAttentionByView.history.map((status) => status.laneId)); + if (historyAttentionLaneIds.has(selectedRebaseItemId) && view !== "history") { + setView("history"); } - }, [activeCategory, rebaseByView.active, rebaseByView.history, selectedRebaseItemId, view]); + }, [activeCategory, rebaseAttentionByView.active, rebaseAttentionByView.history, rebaseByView.active, rebaseByView.history, selectedRebaseItemId, view]); const counts = { integration: integrationByView[view].length, queue: queueByView[view].length, - rebase: rebaseByView[view].length, + rebase: rebaseByView[view].length + rebaseAttentionByView[view].length, }; const activeTheme = CATEGORY_THEMES[activeCategory]; @@ -858,6 +878,7 @@ export function WorkflowsTab({ view === "active" ? ( ; + return ; } - return ; + return ; } if (fam === "anthropic" || cli === "claude") { - return ; + return ; } if (cli === "codex") { - return ; + return ; } if (fam === "openai") { - return ; + return ; } if (fam === "google") { - return ; + return ; } if (fam === "xai") { const hint = `${sdkModelId ?? ""} ${modelId ?? ""}`.toLowerCase(); if (/grok/.test(hint)) { - return ; + return ; } - return ; + return ; } return ; diff --git a/apps/desktop/src/renderer/components/shared/UnifiedModelSelector.tsx b/apps/desktop/src/renderer/components/shared/UnifiedModelSelector.tsx index 6d13fea64..7c7ae4a78 100644 --- a/apps/desktop/src/renderer/components/shared/UnifiedModelSelector.tsx +++ b/apps/desktop/src/renderer/components/shared/UnifiedModelSelector.tsx @@ -61,8 +61,7 @@ function providerAccent(family: string, fallback?: string): string { } function subsectionTabTitle(sub: ModelSubsection): string { - const name = sub.subsectionLabel || sub.label; - return name.trim() || "Models"; + return sub.label.trim() || "Models"; } function modelAvailabilityLabel(model: ModelDescriptor, isAvailable: boolean): string { @@ -140,13 +139,8 @@ function mergeSelectorModels( .map((entry) => String(entry ?? "").trim()) .filter(Boolean), ); - const hasAvailableModels = availableIdSet.size > 0; - for (const model of MODEL_REGISTRY) { if (model.deprecated) continue; - if (hasAvailableModels && model.family === "cursor" && !availableIdSet.has(model.id)) { - continue; - } if (filter && !filter(model)) continue; merged.set(model.id, model); } @@ -534,7 +528,7 @@ export function UnifiedModelSelector({
{prov.subsections.map((sub) => (
- {sub.subsectionLabel || sub.label ? ( + {sub.label ? (
{subsectionTabTitle(sub)}
diff --git a/apps/desktop/src/renderer/components/shared/conflictResolver/ResolverTerminalModal.tsx b/apps/desktop/src/renderer/components/shared/conflictResolver/ResolverTerminalModal.tsx index ef41fc827..f1cef982e 100644 --- a/apps/desktop/src/renderer/components/shared/conflictResolver/ResolverTerminalModal.tsx +++ b/apps/desktop/src/renderer/components/shared/conflictResolver/ResolverTerminalModal.tsx @@ -93,7 +93,8 @@ function resolveRegistryModelId(value: string | null | undefined): string | null function resolveCliRegistryModelId(provider: "codex" | "claude" | "cursor", value: string | null | undefined): string | null { const normalized = (value ?? "").trim().toLowerCase(); if (!normalized.length) return null; - const family = provider === "codex" ? "openai" : provider === "cursor" ? "cursor" : "anthropic"; + const familyMap: Record = { codex: "openai", cursor: "cursor", claude: "anthropic" }; + const family = familyMap[provider] ?? "anthropic"; const match = MODEL_REGISTRY.find( (model) => model.isCliWrapped diff --git a/apps/desktop/src/renderer/components/shared/unifiedModelSelectorGrouping.ts b/apps/desktop/src/renderer/components/shared/unifiedModelSelectorGrouping.ts index b9715715b..5ed22e3cd 100644 --- a/apps/desktop/src/renderer/components/shared/unifiedModelSelectorGrouping.ts +++ b/apps/desktop/src/renderer/components/shared/unifiedModelSelectorGrouping.ts @@ -11,10 +11,8 @@ export type SourceSectionKey = "subscription" | "api" | "local"; export type ModelSubsection = { key: string; - /** Human-readable subsection title (e.g. Cursor CLI line family). */ + /** Human-readable subsection title (e.g. Cursor CLI line family). Empty when a single default bucket. */ label: string; - /** Same as `label`; explicit name for sub-tab UI. Empty when a single default bucket. */ - subsectionLabel: string; models: ModelDescriptor[]; }; @@ -213,11 +211,9 @@ export function buildSourceBlocksForModels( const subMap = famMap.get(family)!; const subsections: ModelSubsection[] = [...subMap.entries()] .map(([key, ms]) => { - const label = subsectionLabel(family, key); return { key, - label, - subsectionLabel: label, + label: subsectionLabel(family, key), models: sortModels(ms, modelOrder), }; }) diff --git a/apps/desktop/src/renderer/lib/modelOptions.ts b/apps/desktop/src/renderer/lib/modelOptions.ts index dee57d023..49b3d4fa5 100644 --- a/apps/desktop/src/renderer/lib/modelOptions.ts +++ b/apps/desktop/src/renderer/lib/modelOptions.ts @@ -61,10 +61,7 @@ export function deriveConfiguredModelIds( for (const auth of status.detectedAuth ?? []) { if (auth.type === "cli-subscription") { - if (!auth.authenticated) continue; - if (auth.cli === "cursor") { - continue; - } + if (!auth.authenticated || auth.cli === "cursor") continue; const familyMap: Record = { claude: "anthropic", codex: "openai" }; const family = auth.cli ? familyMap[auth.cli] : undefined; if (family) addKnownModelIds(ids, family, true); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index e0dc0ed24..cba7c5dde 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -324,7 +324,7 @@ export const useAppStore = create((set, get) => ({ aiStatus != null && (aiStatus.providerConnections?.claude.authAvailable || aiStatus.providerConnections?.codex.authAvailable || - aiStatus.providerConnections?.cursor?.authAvailable || + aiStatus.providerConnections?.cursor.authAvailable || aiStatus.availableProviders.claude || aiStatus.availableProviders.codex || aiStatus.availableProviders.cursor || diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 8dd190d2d..bd925ee1c 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -377,6 +377,9 @@ export const IPC = { prsIssueInventoryMarkEscalated: "ade.prs.issueInventory.markEscalated", prsIssueInventoryGetConvergence: "ade.prs.issueInventory.getConvergence", prsIssueInventoryReset: "ade.prs.issueInventory.reset", + prsConvergenceStateGet: "ade.prs.convergenceState.get", + prsConvergenceStateSave: "ade.prs.convergenceState.save", + prsConvergenceStateDelete: "ade.prs.convergenceState.delete", prsPipelineSettingsGet: "ade.prs.pipelineSettings.get", prsPipelineSettingsSave: "ade.prs.pipelineSettings.save", prsPipelineSettingsDelete: "ade.prs.pipelineSettings.delete", diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 5a9e1fd7e..18f5666c7 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -1036,18 +1036,27 @@ export function resolveProviderGroupForModel( return resolveCliProviderForModel(descriptor) ?? "unified"; } +/** + * Resolve the chat session provider and model ref for a model descriptor. + * CLI-wrapped models route to their native runtime (claude/codex/cursor); + * everything else goes through the unified (in-process) path. + */ +export function resolveChatProviderForDescriptor( + descriptor: ModelDescriptor, +): { provider: ModelProviderGroup; model: string } { + const provider = resolveProviderGroupForModel(descriptor); + return { provider, model: getRuntimeModelRefForDescriptor(descriptor, provider) }; +} + export function getRuntimeModelRefForDescriptor( descriptor: ModelDescriptor, providerHint?: ModelProviderGroup, ): string { const provider = providerHint ?? resolveProviderGroupForModel(descriptor); - if (provider === "codex") { - return descriptor.sdkModelId; - } if (provider === "claude") { return descriptor.shortId; } - if (provider === "cursor") { + if (provider === "codex" || provider === "cursor") { return descriptor.sdkModelId; } return descriptor.id; diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index d49d9d6f1..fed7f3f1a 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -1063,6 +1063,53 @@ export const DEFAULT_PIPELINE_SETTINGS: PipelineSettings = { onRebaseNeeded: "pause", }; +// -------------------------------- +// PR Convergence Runtime State +// -------------------------------- + +export type ConvergenceRuntimeStatus = + | "idle" + | "launching" + | "running" + | "polling" + | "paused" + | "converged" + | "merged" + | "failed" + | "cancelled" + | "stopped"; + +export type ConvergencePollerStatus = + | "idle" + | "scheduled" + | "polling" + | "waiting_for_checks" + | "waiting_for_comments" + | "paused" + | "stopped"; + +export type ConvergenceRuntimeState = { + prId: string; + autoConvergeEnabled: boolean; + status: ConvergenceRuntimeStatus; + pollerStatus: ConvergencePollerStatus; + currentRound: number; + activeSessionId: string | null; + activeLaneId: string | null; + activeHref: string | null; + pauseReason: string | null; + errorMessage: string | null; + lastStartedAt: string | null; + lastPolledAt: string | null; + lastPausedAt: string | null; + lastStoppedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type PrConvergenceState = ConvergenceRuntimeState; +export type PrConvergenceStatePatch = Partial>; + // -------------------------------- // Issue Inventory (PR Convergence Loop) // -------------------------------- @@ -1088,6 +1135,11 @@ export type IssueInventoryItem = { url: string | null; dismissReason: string | null; agentSessionId: string | null; + threadCommentCount?: number | null; + threadLatestCommentId?: string | null; + threadLatestCommentAuthor?: string | null; + threadLatestCommentAt?: string | null; + threadLatestCommentSource?: IssueSource | null; createdAt: string; updatedAt: string; }; @@ -1112,8 +1164,27 @@ export type ConvergenceStatus = { canAutoAdvance: boolean; }; +export const DEFAULT_CONVERGENCE_RUNTIME_STATE: Omit = { + autoConvergeEnabled: false, + status: "idle", + pollerStatus: "idle", + currentRound: 0, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), +}; + export type IssueInventorySnapshot = { prId: string; items: IssueInventoryItem[]; convergence: ConvergenceStatus; + runtime: ConvergenceRuntimeState; }; diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index 06d9807a5..f0f6faa20 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -2325,6 +2325,16 @@ create table if not exists pr_issue_inventory ( foreign key(pr_id) references pull_requests(id) on delete cascade ); +alter table pr_issue_inventory add column thread_comment_count integer; + +alter table pr_issue_inventory add column thread_latest_comment_id text; + +alter table pr_issue_inventory add column thread_latest_comment_author text; + +alter table pr_issue_inventory add column thread_latest_comment_at text; + +alter table pr_issue_inventory add column thread_latest_comment_source text; + create index if not exists idx_inventory_pr_state on pr_issue_inventory(pr_id, state); create table if not exists pr_pipeline_settings ( @@ -2336,3 +2346,23 @@ create table if not exists pr_pipeline_settings ( updated_at text not null, foreign key(pr_id) references pull_requests(id) on delete cascade ); + +create table if not exists pr_convergence_state ( + pr_id text primary key, + auto_converge_enabled integer not null default 0, + status text not null default 'idle', + poller_status text not null default 'idle', + current_round integer not null default 0, + active_session_id text, + active_lane_id text, + active_href text, + pause_reason text, + error_message text, + last_started_at text, + last_polled_at text, + last_paused_at text, + last_stopped_at text, + created_at text not null, + updated_at text not null, + foreign key(pr_id) references pull_requests(id) on delete cascade + ); diff --git a/apps/mcp-server/src/bootstrap.ts b/apps/mcp-server/src/bootstrap.ts index 551d434de..e728b209c 100644 --- a/apps/mcp-server/src/bootstrap.ts +++ b/apps/mcp-server/src/bootstrap.ts @@ -17,6 +17,7 @@ import { createPtyService } from "../../desktop/src/main/services/pty/ptyService import { createTestService } from "../../desktop/src/main/services/tests/testService"; import type { createAgentChatService } from "../../desktop/src/main/services/chat/agentChatService"; import type { createPrService } from "../../desktop/src/main/services/prs/prService"; +import { createIssueInventoryService } from "../../desktop/src/main/services/prs/issueInventoryService"; import { createMemoryService } from "../../desktop/src/main/services/memory/memoryService"; import { createCtoStateService } from "../../desktop/src/main/services/cto/ctoStateService"; import { createWorkerAgentService } from "../../desktop/src/main/services/cto/workerAgentService"; @@ -127,6 +128,7 @@ export type AdeMcpRuntime = { testService: ReturnType; agentChatService?: ReturnType | null; prService?: ReturnType; + issueInventoryService: ReturnType; fileService?: ReturnType | null; memoryService: ReturnType; ctoStateService: ReturnType; @@ -273,6 +275,7 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace projectConfigService, broadcastEvent: () => {} }); + const issueInventoryService = createIssueInventoryService({ db }); // Ensure MCP-specific tables exist (evaluation framework) db.run(` @@ -352,10 +355,21 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace } const externalMcpConfigWatcher = (() => { try { - return fs.watch(paths.adeDir, (_eventType, fileName) => { + const watcher = fs.watch(paths.adeDir, (_eventType, fileName) => { if (String(fileName ?? "").trim() !== "local.secret.yaml") return; externalMcpService.reload(); }); + watcher.on("error", (error) => { + logger.warn("external_mcp.bootstrap_watch_runtime_failed", { + error: error instanceof Error ? error.message : String(error), + }); + try { + watcher.close(); + } catch { + // Ignore watcher shutdown errors during degraded headless startup. + } + }); + return watcher; } catch (error) { logger.warn("external_mcp.bootstrap_watch_failed", { error: error instanceof Error ? error.message : String(error), @@ -456,6 +470,7 @@ export async function createAdeMcpRuntime(args: { projectRoot: string; workspace ptyService, testService, agentChatService: headlessLinearServices.agentChatService as unknown as ReturnType | null, + issueInventoryService, memoryService, ctoStateService, workerAgentService, diff --git a/apps/mcp-server/src/jsonrpc.ts b/apps/mcp-server/src/jsonrpc.ts index 032258f7b..36c586db0 100644 --- a/apps/mcp-server/src/jsonrpc.ts +++ b/apps/mcp-server/src/jsonrpc.ts @@ -339,7 +339,12 @@ export type JsonRpcServerHandle = (() => void) & { notify: (method: string, params?: unknown) => void; }; -export function startJsonRpcServer(handler: JsonRpcHandler, transport: JsonRpcTransport): JsonRpcServerHandle { +export interface JsonRpcServerOptions { + /** When true, oversized buffers close the connection instead of calling process.exit(1). */ + nonFatal?: boolean; +} + +export function startJsonRpcServer(handler: JsonRpcHandler, transport: JsonRpcTransport, options?: JsonRpcServerOptions): JsonRpcServerHandle { const writeFn = transport.write.bind(transport); let buffer: Buffer = Buffer.alloc(0); let stopped = false; @@ -396,7 +401,9 @@ export function startJsonRpcServer(handler: JsonRpcHandler, transport: JsonRpcTr }, responseTransport ?? "framed", writeFn); stopped = true; transport.close(); - process.nextTick(() => process.exit(1)); + if (!options?.nonFatal) { + process.nextTick(() => process.exit(1)); + } return; } diff --git a/apps/mcp-server/src/mcpServer.test.ts b/apps/mcp-server/src/mcpServer.test.ts index 9b74c96f7..0bff73a4c 100644 --- a/apps/mcp-server/src/mcpServer.test.ts +++ b/apps/mcp-server/src/mcpServer.test.ts @@ -202,6 +202,83 @@ function createRuntime() { stop: vi.fn(), getLogTail: vi.fn(() => "") }, + issueInventoryService: (() => { + const runtimeByPr = new Map>(); + const inventoryByPr = new Map>(); + const pipelineByPr = new Map>(); + + const defaultRuntime = (prId: string) => ({ + prId, + autoConvergeEnabled: false, + status: "idle", + pollerStatus: "idle", + currentRound: 0, + activeSessionId: null, + activeLaneId: null, + activeHref: null, + pauseReason: null, + errorMessage: null, + lastStartedAt: null, + lastPolledAt: null, + lastPausedAt: null, + lastStoppedAt: null, + createdAt: "2026-03-17T19:00:00.000Z", + updatedAt: "2026-03-17T19:00:00.000Z", + }); + + const defaultPipeline = () => ({ + autoMerge: false, + mergeMethod: "repo_default", + maxRounds: 5, + onRebaseNeeded: "pause", + }); + + return { + syncFromPrData: vi.fn((prId: string) => { + const runtime = { ...defaultRuntime(prId), ...runtimeByPr.get(prId) }; + const existingSnapshot = inventoryByPr.get(prId) ?? null; + const snapshot = { + prId, + items: existingSnapshot?.items ?? [], + convergence: { + currentRound: typeof runtime.currentRound === "number" ? runtime.currentRound : 0, + maxRounds: { ...defaultPipeline(), ...pipelineByPr.get(prId) }.maxRounds, + issuesPerRound: [], + totalNew: 0, + totalFixed: 0, + totalDismissed: 0, + totalEscalated: 0, + totalSentToAgent: 0, + isConverging: false, + canAutoAdvance: false, + }, + runtime, + }; + inventoryByPr.set(prId, snapshot); + return snapshot; + }), + getConvergenceRuntime: vi.fn((prId: string) => ({ + ...defaultRuntime(prId), + ...runtimeByPr.get(prId), + })), + getPipelineSettings: vi.fn((prId: string) => ({ + ...defaultPipeline(), + ...pipelineByPr.get(prId), + })), + getNewItems: vi.fn((_prId: string) => []), + markSentToAgent: vi.fn(), + saveConvergenceRuntime: vi.fn((prId: string, state: Record) => { + const existing = runtimeByPr.get(prId) ?? {}; + const merged = { ...defaultRuntime(prId), ...existing, ...state }; + runtimeByPr.set(prId, merged); + return merged; + }), + savePipelineSettings: vi.fn((prId: string, settings: Record) => { + const existing = pipelineByPr.get(prId) ?? {}; + pipelineByPr.set(prId, { ...existing, ...settings }); + }), + }; + })(), prService: { simulateIntegration: vi.fn(async () => ({ steps: [], conflicts: [], clean: true })), createQueuePrs: vi.fn(async () => ({ groupId: "group-1", prs: [] })), diff --git a/apps/mcp-server/src/mcpServer.ts b/apps/mcp-server/src/mcpServer.ts index df536fd4e..44db993f0 100644 --- a/apps/mcp-server/src/mcpServer.ts +++ b/apps/mcp-server/src/mcpServer.ts @@ -1921,6 +1921,7 @@ async function runCtoOperatorBridgeTool( currentSessionId: session.identity.callerId || "mcp-cto", defaultLaneId, defaultModelId, + sessionService: runtime.sessionService, resolveExecutionLane: async ({ requestedLaneId }) => requestedLaneId?.trim() || defaultLaneId, laneService: runtime.laneService, missionService: runtime.missionService, @@ -1929,6 +1930,7 @@ async function runCtoOperatorBridgeTool( linearDispatcherService: runtime.linearDispatcherService ?? null, flowPolicyService: runtime.flowPolicyService ?? null, prService: runtime.prService ?? null, + issueInventoryService: runtime.issueInventoryService, fileService: runtime.fileService ?? null, processService: runtime.processService ?? null, issueTracker: runtime.linearIssueTracker ?? null, @@ -1937,6 +1939,7 @@ async function runCtoOperatorBridgeTool( getChatTranscript: agentChatService.getChatTranscript, createChat: agentChatService.createSession, updateChatSession: agentChatService.updateSession, + previewSessionToolNames: agentChatService.previewSessionToolNames, sendChatMessage: agentChatService.sendMessage, interruptChat: agentChatService.interrupt, resumeChat: agentChatService.resumeSession, diff --git a/apps/web/public/images/features/agent-chat.png b/apps/web/public/images/features/agent-chat.png new file mode 100644 index 000000000..dc57040e3 Binary files /dev/null and b/apps/web/public/images/features/agent-chat.png differ diff --git a/apps/web/public/images/features/automations.svg b/apps/web/public/images/features/automations.svg deleted file mode 100644 index 0466ef22c..000000000 --- a/apps/web/public/images/features/automations.svg +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/features/conflicts.svg b/apps/web/public/images/features/conflicts.svg deleted file mode 100644 index c7109f585..000000000 --- a/apps/web/public/images/features/conflicts.svg +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/features/files.png b/apps/web/public/images/features/files.png new file mode 100644 index 000000000..3d8c94049 Binary files /dev/null and b/apps/web/public/images/features/files.png differ diff --git a/apps/web/public/images/features/files.svg b/apps/web/public/images/features/files.svg deleted file mode 100644 index 4ac31be61..000000000 --- a/apps/web/public/images/features/files.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/features/git history.png b/apps/web/public/images/features/git history.png new file mode 100644 index 000000000..42bcdbca0 Binary files /dev/null and b/apps/web/public/images/features/git history.png differ diff --git a/apps/web/public/images/features/graph.svg b/apps/web/public/images/features/graph.svg deleted file mode 100644 index 6d088ccbf..000000000 --- a/apps/web/public/images/features/graph.svg +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/features/lanes.png b/apps/web/public/images/features/lanes.png new file mode 100644 index 000000000..e015876d2 Binary files /dev/null and b/apps/web/public/images/features/lanes.png differ diff --git a/apps/web/public/images/features/lanes.svg b/apps/web/public/images/features/lanes.svg deleted file mode 100644 index 06b5ca6da..000000000 --- a/apps/web/public/images/features/lanes.svg +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/features/modelconfig.png b/apps/web/public/images/features/modelconfig.png new file mode 100644 index 000000000..2c02d88be Binary files /dev/null and b/apps/web/public/images/features/modelconfig.png differ diff --git a/apps/web/public/images/features/packs.svg b/apps/web/public/images/features/packs.svg deleted file mode 100644 index b712a4754..000000000 --- a/apps/web/public/images/features/packs.svg +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/features/prs.png b/apps/web/public/images/features/prs.png new file mode 100644 index 000000000..77f5f6ece Binary files /dev/null and b/apps/web/public/images/features/prs.png differ diff --git a/apps/web/public/images/features/run.svg b/apps/web/public/images/features/run.svg deleted file mode 100644 index 406c8ad69..000000000 --- a/apps/web/public/images/features/run.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/features/terminals.png b/apps/web/public/images/features/terminals.png new file mode 100644 index 000000000..e75bc6af0 Binary files /dev/null and b/apps/web/public/images/features/terminals.png differ diff --git a/apps/web/public/images/features/terminals.svg b/apps/web/public/images/features/terminals.svg deleted file mode 100644 index 05f01849a..000000000 --- a/apps/web/public/images/features/terminals.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/images/features/workspacegraph.png b/apps/web/public/images/features/workspacegraph.png new file mode 100644 index 000000000..acc5977d8 Binary files /dev/null and b/apps/web/public/images/features/workspacegraph.png differ diff --git a/apps/web/public/images/splash/left.png b/apps/web/public/images/splash/left.png new file mode 100644 index 000000000..f38bb0114 Binary files /dev/null and b/apps/web/public/images/splash/left.png differ diff --git a/apps/web/public/images/splash/middle.png b/apps/web/public/images/splash/middle.png new file mode 100644 index 000000000..2bef40835 Binary files /dev/null and b/apps/web/public/images/splash/middle.png differ diff --git a/apps/web/public/images/splash/right.png b/apps/web/public/images/splash/right.png new file mode 100644 index 000000000..73172e3c7 Binary files /dev/null and b/apps/web/public/images/splash/right.png differ diff --git a/apps/web/src/app/pages/HomePage.tsx b/apps/web/src/app/pages/HomePage.tsx index 4e3c39ef0..8ef56a53a 100644 --- a/apps/web/src/app/pages/HomePage.tsx +++ b/apps/web/src/app/pages/HomePage.tsx @@ -1,12 +1,16 @@ import { ArrowUpRight, BookOpen, + Bot, Download, Github, + GitMerge, Layers, MonitorCheck, + Package, Play, - Settings2, + Workflow, + Zap, } from "lucide-react"; import { Fragment } from "react"; import { Container } from "../../components/Container"; @@ -39,12 +43,48 @@ const COMPETITORS = [ { name: "GitHub", logo: "/images/competitors/github.png" }, ] as const; -const CAPABILITIES = [ - { icon: MonitorCheck, label: "Computer Use", detail: "Screenshot-based verification of agent output" }, - { icon: Layers, label: "35+ MCP Tools", detail: "Built-in server for file ops, git, search, and more" }, - { icon: Settings2, label: "Multi-Provider", detail: "Claude, Codex, Gemini, local models via BYOK" }, - { icon: Play, label: "Process Monitor", detail: "Track every terminal command and its output" }, -]; +const ALSO_BUILT_IN = [ + { + icon: Bot, + label: "CTO agent", + detail: "A long-lived lead for architecture and decisions, with memory and team workflows.", + }, + { + icon: Workflow, + label: "Missions", + detail: "Coordinated multi-step runs with visibility across phases — planning, testing, and PRs.", + }, + { + icon: Package, + label: "Unified memory", + detail: "Vector-indexed memory across projects and agents so work compounds instead of resetting.", + }, + { + icon: Zap, + label: "Automations", + detail: "Event-driven agents on git events, PR activity, or schedules — with guardrails while you are away.", + }, + { + icon: GitMerge, + label: "Merge conflicts", + detail: "Resolve conflicts with side-by-side diffs and a focused flow so you can land merges in one place.", + }, + { + icon: MonitorCheck, + label: "Computer use", + detail: "Screenshot-based verification of agent output when you need proof, not just prose.", + }, + { + icon: Layers, + label: "35+ MCP tools", + detail: "Built-in server for file ops, git, search, and more — desktop and headless paths.", + }, + { + icon: Play, + label: "Process monitor", + detail: "Track every terminal command agents spawn and inspect output in one timeline.", + }, +] as const; /* ────────────────────────────────────────────── Quickstart copy command @@ -104,7 +144,7 @@ export function HomePage() { return ( {/* ── HERO — Logo Equation + ADE ─────────── */} -
+
{/* Background: gradient mesh + dot texture */}
{/* Hero app visual */} - -
+ +
@@ -222,11 +262,15 @@ export function HomePage() {

Also built in

+

+ Everything below ships in the same app; the gallery above highlights the visuals we are showcasing on + the site right now. +

- {CAPABILITIES.map((cap, idx) => ( - + {ALSO_BUILT_IN.map((cap, idx) => ( +
{cap.label}
diff --git a/apps/web/src/components/FeaturePlaceholder.tsx b/apps/web/src/components/FeaturePlaceholder.tsx deleted file mode 100644 index c3d5b0936..000000000 --- a/apps/web/src/components/FeaturePlaceholder.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { cn } from "../lib/cn"; - -export function FeaturePlaceholder({ colorClass }: { colorClass: string }) { - return ( -
-
-
- -
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); -} diff --git a/apps/web/src/components/ProductShowcase.tsx b/apps/web/src/components/ProductShowcase.tsx index b3212796e..d878c5c9e 100644 --- a/apps/web/src/components/ProductShowcase.tsx +++ b/apps/web/src/components/ProductShowcase.tsx @@ -1,210 +1,99 @@ -import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; -import { useState } from "react"; import { motion, useReducedMotion } from "framer-motion"; -import { - Bot, - FileEdit, - GitBranch, - GitPullRequest, - KeyRound, - MessageSquare, - Package, - Share2, - SquareTerminal, - Workflow, - Zap, -} from "lucide-react"; +import { KeyRound } from "lucide-react"; import { Container } from "./Container"; -import { FeaturePlaceholder } from "./FeaturePlaceholder"; import { cn } from "../lib/cn"; import { ADE_EASE_OUT } from "../lib/motion"; -const PROVIDERS = [ - "Claude", - "Codex", - "Gemini", - "OpenAI-compatible", - "Local models", - "BYOK", - "Your subscription", -] as const; - -function ScreenshotSlot({ - slug, - colorClass, - className, -}: { - slug: string; - colorClass: string; - className?: string; -}) { - const [useFallback, setUseFallback] = useState(false); - const src = `/images/screenshots/${slug}.png`; - - return ( -
- {!useFallback ? ( - setUseFallback(true)} - /> - ) : ( - - )} -
- ); -} - -type ProductItem = { - slug: string; - icon: LucideIcon; +/** + * Product screenshots: `apps/web/public/images/features/` + * Filenames match the assets under `apps/web/public/images/features/`. + */ +type FeatureAsset = { + /** Basename under `/images/features/` (may include spaces). */ + image: string; name: string; tagline: string; description: string; - color: string; - bgColor: string; - borderColor: string; - colSpan: string; - variant?: "chat"; }; -const ITEMS: ProductItem[] = [ +const FEATURES: FeatureAsset[] = [ { - slug: "agent-chat", - icon: MessageSquare, + image: "agent-chat.png", name: "Agent chat", tagline: "Native · multi-provider", description: - "First-class sessions with tools and context — Claude, Codex, Gemini, local models, and what you already subscribe to. Not a browser bolt-on.", - color: "text-sky-400", - bgColor: "bg-sky-500/10", - borderColor: "border-sky-500/20", - colSpan: "lg:col-span-7", - variant: "chat", + "First-class sessions with tools and context — Claude, Codex, Gemini, local models, and what you already subscribe to.", }, { - slug: "lanes", - icon: GitBranch, + image: "lanes.png", name: "Lanes", tagline: "Parallel git worktrees", description: "Each agent in its own worktree — run builds, tests, and installs at the same time without stepping on the same tree.", - color: "text-violet-400", - bgColor: "bg-violet-500/10", - borderColor: "border-violet-500/20", - colSpan: "lg:col-span-5", }, { - slug: "terminals", - icon: SquareTerminal, + image: "terminals.png", name: "Terminals", - tagline: "Real PTY output", + tagline: "Live PTY output", description: "Shells with live streams so you see every command agents run and every line of output.", - color: "text-emerald-400", - bgColor: "bg-emerald-500/10", - borderColor: "border-emerald-500/20", - colSpan: "lg:col-span-4", }, { - slug: "prs", - icon: GitPullRequest, - name: "PRs", - tagline: "Review in one place", - description: "Open, review, and track pull requests from the same desktop shell as your agents.", - color: "text-amber-400", - bgColor: "bg-amber-500/10", - borderColor: "border-amber-500/20", - colSpan: "lg:col-span-4", - }, - { - slug: "graph", - icon: Share2, - name: "Workspace graph", - tagline: "See how work connects", - description: "A visual map of your workspace — including how PRs and lanes relate to the repo.", - color: "text-cyan-400", - bgColor: "bg-cyan-500/10", - borderColor: "border-cyan-500/20", - colSpan: "lg:col-span-4", - }, - { - slug: "files", - icon: FileEdit, + image: "files.png", name: "Files", tagline: "Edit in place", description: "Jump from chat or review into the file surface without losing context.", - color: "text-rose-400", - bgColor: "bg-rose-500/10", - borderColor: "border-rose-500/20", - colSpan: "lg:col-span-4", }, { - slug: "cto-agent", - icon: Bot, - name: "CTO agent", - tagline: "Persistent lead with memory", - description: - "A long-lived agent for architecture and decisions — orchestration-style lead inside the app, with Linear and team workflows.", - color: "text-indigo-400", - bgColor: "bg-indigo-500/10", - borderColor: "border-indigo-500/20", - colSpan: "lg:col-span-4", + image: "workspacegraph.png", + name: "Workspace graph", + tagline: "See how work connects", + description: "A visual map of your workspace — including how PRs and lanes relate to the repo.", }, { - slug: "missions", - icon: Workflow, - name: "Missions", - tagline: "Coordinated multi-step runs", - description: - "Planned DAGs with visibility across phases — planning, testing, PRs — not one-off chat blasts.", - color: "text-fuchsia-400", - bgColor: "bg-fuchsia-500/10", - borderColor: "border-fuchsia-500/20", - colSpan: "lg:col-span-4", + image: "prs.png", + name: "PRs", + tagline: "Review in one place", + description: "Open, review, and track pull requests from the same desktop shell as your agents.", }, { - slug: "automations", - icon: Zap, - name: "Automations", - tagline: "Event-driven agents", - description: - "Trigger on push, PR events, or schedules — with budgets and guardrails while you are away.", - color: "text-orange-400", - bgColor: "bg-orange-500/10", - borderColor: "border-orange-500/20", - colSpan: "lg:col-span-6", + image: "git history.png", + name: "Git history", + tagline: "Timeline in context", + description: "Inspect commits and history beside the lane and file you are working in — without leaving ADE.", }, { - slug: "unified-memory", - icon: Package, - name: "Unified memory", - tagline: "Agents that remember", - description: - "Vector-indexed memory across projects, agents, and missions so work compounds instead of resetting.", - color: "text-teal-400", - bgColor: "bg-teal-500/10", - borderColor: "border-teal-500/20", - colSpan: "lg:col-span-6", + image: "modelconfig.png", + name: "Model configuration", + tagline: "Your keys and models", + description: "Wire providers, models, and API keys in one settings surface — BYOK and subscriptions you already use.", }, ]; +function featureSrc(image: string) { + return `/images/features/${encodeURIComponent(image)}`; +} + +function FeatureImage({ image, title }: { image: string; title: string }) { + return ( +
+ {title} +
+ ); +} + function ShowcaseCard({ item, delay, children, }: { - item: ProductItem; + item: FeatureAsset; delay: number; children: ReactNode; }) { @@ -213,13 +102,12 @@ function ShowcaseCard({ {children} @@ -227,36 +115,6 @@ function ShowcaseCard({ ); } -function ProviderMarquee() { - const reduceMotion = useReducedMotion(); - return ( -
-
-
- {reduceMotion ? ( -
- {PROVIDERS.map((p) => ( - {p} - ))} -
- ) : ( -
-
- {PROVIDERS.map((p) => ( - {p} - ))} -
-
- {PROVIDERS.map((p) => ( - {p} - ))} -
-
- )} -
- ); -} - export function ProductShowcase() { const reduceMotion = useReducedMotion(); @@ -293,43 +151,19 @@ export function ProductShowcase() {

- Chat, lanes, terminals, PRs, graph, files, CTO agent, missions, automations, and memory — - all in one shell. Hover a preview to see which PNG to add, or drop files into{" "} - - apps/web/public/images/screenshots/ - - . + Chat, lanes, terminals, files, the workspace graph, pull requests, git history, and model setup — all + captured from the real app. More capabilities are listed below under “Also built in.”

-
- {ITEMS.map((item, idx) => ( - +
+ {FEATURES.map((item, idx) => ( +
-
-
- -
- {item.variant === "chat" ? ( - - Native - - ) : null} -
+

{item.name}

-

{item.tagline}

+

{item.tagline}

{item.description}

- -
- -
- {item.variant === "chat" ? : null}
))} @@ -340,7 +174,7 @@ export function ProductShowcase() { whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }} viewport={{ once: true, amount: 0.35 }} transition={{ duration: 0.5, delay: 0.1, ease: ADE_EASE_OUT }} - className="mt-6 flex flex-col items-center gap-4 rounded-2xl border border-accent/20 bg-gradient-to-br from-accent/10 via-card/60 to-transparent px-6 py-8 text-center sm:flex-row sm:text-left" + className="mt-10 flex flex-col items-center gap-4 rounded-2xl border border-accent/20 bg-gradient-to-br from-accent/10 via-card/60 to-transparent px-6 py-8 text-center sm:flex-row sm:text-left" >
@@ -348,8 +182,8 @@ export function ProductShowcase() {

Your keys. Your seats.

- Wire ADE to API keys and subscriptions you already use — we unify the workspace, not your - provider billing. + Wire ADE to API keys and subscriptions you already use — we unify the workspace, not your provider + billing.

diff --git a/apps/web/src/components/ui/HeroVisual.tsx b/apps/web/src/components/ui/HeroVisual.tsx index 8de5b3b82..c03056db9 100644 --- a/apps/web/src/components/ui/HeroVisual.tsx +++ b/apps/web/src/components/ui/HeroVisual.tsx @@ -1,110 +1,63 @@ import { motion } from "framer-motion"; +const SLIDES = [ + { src: "/images/splash/left.png", alt: "ADE — lanes and missions" }, + { src: "/images/splash/middle.png", alt: "ADE — main workspace" }, + { src: "/images/splash/right.png", alt: "ADE — editor and tools" }, +] as const; + export function HeroVisual() { return ( -
- {/* Abstract Background Blurs */} +