diff --git a/apps/desktop/src/main/adeMcpProxy.ts b/apps/desktop/src/main/adeMcpProxy.ts index b9d7ae18c..e38130352 100644 --- a/apps/desktop/src/main/adeMcpProxy.ts +++ b/apps/desktop/src/main/adeMcpProxy.ts @@ -18,6 +18,9 @@ type RuntimeRoots = { workspaceRoot: string; }; +const MCP_SOCKET_CONNECT_TIMEOUT_MS = 5_000; +const MCP_SOCKET_CONNECT_RETRY_DELAY_MS = 150; + function resolveCliArg(flag: string): string | null { const args = process.argv.slice(2); for (let i = 0; i < args.length; i += 1) { @@ -118,6 +121,43 @@ function relayProxyInputWithIdentity(socket: net.Socket): void { }); } +function isRetriableSocketConnectError(error: NodeJS.ErrnoException): boolean { + return error.code === "ENOENT" || error.code === "ECONNREFUSED"; +} + +async function connectToSocketWithRetry(socketPath: string): Promise { + const deadline = Date.now() + MCP_SOCKET_CONNECT_TIMEOUT_MS; + + while (true) { + const socket = net.createConnection(socketPath); + try { + await new Promise((resolve, reject) => { + const handleConnect = () => { + socket.off("error", handleError); + resolve(); + }; + const handleError = (error: NodeJS.ErrnoException) => { + socket.off("connect", handleConnect); + reject(error); + }; + socket.once("connect", handleConnect); + socket.once("error", handleError); + }); + return socket; + } catch (error) { + socket.destroy(); + const nextError = error as NodeJS.ErrnoException; + if (!isRetriableSocketConnectError(nextError) || Date.now() >= deadline) { + throw nextError; + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, MCP_SOCKET_CONNECT_RETRY_DELAY_MS); + timer.unref?.(); + }); + } + } +} + async function main(): Promise { const roots = resolveRuntimeRoots(); const socketPath = process.env.ADE_MCP_SOCKET_PATH?.trim() || resolveAdeLayout(roots.projectRoot).socketPath; @@ -134,25 +174,20 @@ async function main(): Promise { process.exit(0); } - const socket = net.createConnection(socketPath); - let connected = false; + const socket = await connectToSocketWithRetry(socketPath); socket.on("error", (err) => { - const prefix = connected ? "[ade-mcp-proxy]" : "[ade-mcp-proxy] Failed to connect"; - process.stderr.write(`${prefix}: ${err.message}\n`); + process.stderr.write(`[ade-mcp-proxy]: ${err.message}\n`); process.exit(1); }); - socket.on("connect", () => { - connected = true; - process.stdin.resume(); - relayProxyInputWithIdentity(socket); - socket.pipe(process.stdout); - }); - socket.on("close", () => { - process.exit(connected ? 0 : 1); + process.exit(0); }); + + process.stdin.resume(); + relayProxyInputWithIdentity(socket); + socket.pipe(process.stdout); } void main().catch((error) => { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 59d8d5aae..6114715d9 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -40,6 +40,7 @@ import { createPrService } from "./services/prs/prService"; import { createPrPollingService } from "./services/prs/prPollingService"; import { createQueueLandingService } from "./services/prs/queueLandingService"; import { createIssueInventoryService } from "./services/prs/issueInventoryService"; +import { createPrSummaryService } from "./services/prs/prSummaryService"; import { detectDefaultBaseRef, resolveRepoRoot, @@ -76,7 +77,6 @@ import { createRebaseSuggestionService } from "./services/lanes/rebaseSuggestion import { createAutoRebaseService } from "./services/lanes/autoRebaseService"; import { createMissionService } from "./services/missions/missionService"; import { createMissionPreflightService } from "./services/missions/missionPreflightService"; -import { createCompactionFlushService } from "./services/memory/compactionFlushService"; import { createBatchConsolidationService } from "./services/memory/batchConsolidationService"; import { createEmbeddingService } from "./services/memory/embeddingService"; import { createEmbeddingWorkerService } from "./services/memory/embeddingWorkerService"; @@ -1125,7 +1125,6 @@ app.whenReady().then(async () => { }); const reconciledSessions = sessionService.reconcileStaleRunningSessions({ status: "disposed", - excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor"], }); if (reconciledSessions > 0) { logger.warn("sessions.reconciled_stale_running", { @@ -1378,6 +1377,7 @@ app.whenReady().then(async () => { logger, prService, projectConfigService, + db, onEvent: (event) => emitProjectEvent(projectRoot, IPC.prsEvent, event), onPullRequestsChanged: async ({ changedPrs, changes }) => { if (changedPrs.length > 0) { @@ -1456,6 +1456,14 @@ app.whenReady().then(async () => { const issueInventoryService = createIssueInventoryService({ db }); + const prSummaryService = createPrSummaryService({ + db, + logger, + projectRoot, + prService, + aiIntegrationService, + }); + const fileService = createFileService({ laneService, onLaneWorktreeMutation: ({ laneId, reason }) => { @@ -1620,10 +1628,6 @@ app.whenReady().then(async () => { memoryService, }); memoryFilesServiceRef = memoryFilesService; - const compactionFlushService = createCompactionFlushService(undefined, { - logger, - }); - aiIntegrationService.setCompactionFlushService(compactionFlushService); const batchConsolidationService = createBatchConsolidationService({ db, logger, @@ -1905,32 +1909,6 @@ app.whenReady().then(async () => { openclawBridgeServiceRef?.onAgentChatEvent(event); emitProjectEvent(projectRoot, IPC.agentChatEvent, event); - // Compaction flush: when context compaction occurs, trigger a flush steer - // so the agent can save durable discoveries to memory before they are lost. - if (event.event.type === "context_compact") { - const sid = event.sessionId; - const compactEvt = event.event as { preTokens?: number }; - void compactionFlushService - .beforeCompaction({ - sessionId: sid, - boundaryId: `chat:${sid}:${Date.now()}`, - conversationTokenCount: compactEvt.preTokens ?? 200_000, - maxTokens: 200_000, - flushTurn: async ({ prompt }) => { - try { - await agentChatService.steer({ - sessionId: sid, - text: prompt, - }); - return { status: "flushed" }; - } catch { - return { status: "budget_exceeded" }; - } - }, - }) - .catch(() => {}); - } - // Capture agent session errors as failure gotchas for the memory system if (event.event.type === "error" && event.provenance?.runId) { const prov = event.provenance; @@ -2930,7 +2908,19 @@ app.whenReady().then(async () => { }); conn.on("error", () => {}); // ignore connection errors }); - mcpSocketServer.listen(mcpSocketPath); + await new Promise((resolve, reject) => { + const handleListening = () => { + mcpSocketServer.off("error", handleError); + resolve(); + }; + const handleError = (error: Error) => { + mcpSocketServer.off("listening", handleListening); + reject(error); + }; + mcpSocketServer.once("listening", handleListening); + mcpSocketServer.once("error", handleError); + mcpSocketServer.listen(mcpSocketPath); + }); logger.info("mcp.socket_server_started", { socketPath: mcpSocketPath }); return { @@ -2970,6 +2960,7 @@ app.whenReady().then(async () => { computerUseArtifactBrokerService, queueLandingService, issueInventoryService, + prSummaryService, jobEngine, automationService, automationPlannerService, @@ -3068,6 +3059,7 @@ app.whenReady().then(async () => { prPollingService: null, queueLandingService: null, issueInventoryService: null, + prSummaryService: null, jobEngine: null, automationService: null, automationPlannerService: null, diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index aeb9f6dc8..8ad45414b 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -14,6 +14,10 @@ const mockState = vi.hoisted(() => ({ probeClaudeRuntimeHealth: vi.fn(), resetClaudeRuntimeProbeCache: vi.fn(), runProviderTask: vi.fn(), + clearOpenCodeInventoryCache: vi.fn(), + peekOpenCodeInventoryCache: vi.fn(), + probeOpenCodeProviderInventory: vi.fn(), + resolveOpenCodeBinary: vi.fn(), })); vi.mock("./authDetector", () => ({ @@ -53,6 +57,16 @@ vi.mock("./providerTaskRunner", () => ({ runProviderTask: (...args: unknown[]) => mockState.runProviderTask(...args), })); +vi.mock("../opencode/openCodeInventory", () => ({ + clearOpenCodeInventoryCache: (...args: unknown[]) => mockState.clearOpenCodeInventoryCache(...args), + peekOpenCodeInventoryCache: (...args: unknown[]) => mockState.peekOpenCodeInventoryCache(...args), + probeOpenCodeProviderInventory: (...args: unknown[]) => mockState.probeOpenCodeProviderInventory(...args), +})); + +vi.mock("../opencode/openCodeBinaryManager", () => ({ + resolveOpenCodeBinary: (...args: unknown[]) => mockState.resolveOpenCodeBinary(...args), +})); + import { getLocalProviderDefaultEndpoint } from "../../../shared/modelRegistry"; import { createAiIntegrationService } from "./aiIntegrationService"; @@ -217,6 +231,18 @@ beforeEach(() => { }); mockState.initModelsDevService.mockResolvedValue(new Map()); mockState.probeClaudeRuntimeHealth.mockResolvedValue(undefined); + mockState.clearOpenCodeInventoryCache.mockImplementation(() => undefined); + mockState.peekOpenCodeInventoryCache.mockReturnValue(null); + mockState.probeOpenCodeProviderInventory.mockResolvedValue({ + modelIds: ["opencode/openai/gpt-5.4-mini"], + providers: [{ id: "openai", name: "OpenAI", connected: true, modelCount: 1 }], + error: null, + descriptors: [], + }); + mockState.resolveOpenCodeBinary.mockReturnValue({ + path: "/Users/admin/.opencode/bin/opencode", + source: "user-installed", + }); }); describe("aiIntegrationService", () => { @@ -364,4 +390,28 @@ describe("aiIntegrationService", () => { expect(mockState.buildProviderConnections).toHaveBeenCalledTimes(1); expect(secondStatus).toEqual(firstStatus); }); + + it("does not cold-probe OpenCode inventory on default getStatus", async () => { + const { service } = makeService(); + + const status = await service.getStatus(); + + expect(status.opencodeBinaryInstalled).toBe(true); + expect(status.opencodeProviders).toEqual([]); + expect(status.availableModelIds).not.toContain("opencode/openai/gpt-5.4-mini"); + expect(mockState.peekOpenCodeInventoryCache).toHaveBeenCalledTimes(1); + expect(mockState.probeOpenCodeProviderInventory).not.toHaveBeenCalled(); + }); + + it("probes OpenCode inventory when explicitly refreshed", async () => { + const { service } = makeService(); + + const status = await service.getStatus({ refreshOpenCodeInventory: true }); + + expect(mockState.probeOpenCodeProviderInventory).toHaveBeenCalledTimes(1); + expect(status.opencodeProviders).toEqual([ + { id: "openai", name: "OpenAI", connected: true, modelCount: 1 }, + ]); + expect(status.availableModelIds).toContain("opencode/openai/gpt-5.4-mini"); + }); }); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 844278b94..28c01178f 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -46,7 +46,6 @@ import { isRecord } from "../shared/utils"; import { parseStructuredOutput } from "./utils"; import { getApiKeyStoreStatus } from "./apiKeyStore"; import type { createMemoryService } from "../memory/memoryService"; -import type { CompactionFlushService } from "../memory/compactionFlushService"; import { inspectLocalProvider } from "./localModelDiscovery"; import { discoverCursorCliModelDescriptors, clearCursorCliModelsCache } from "../chat/cursorModelsDiscovery"; import { resolveCursorAgentExecutable } from "./cursorAgentExecutable"; @@ -655,7 +654,6 @@ export function createAiIntegrationService(args: { projectRoot: string; }) { const { db, logger, projectConfigService, projectRoot } = args; - let compactionFlushService: CompactionFlushService | null = null; // Non-blocking: fetch models.dev data and enrich pricing + registry initModelsDevService().then((modelData) => { @@ -1257,17 +1255,6 @@ export function createAiIntegrationService(args: { opencodeInventoryError = peeked.error; opencodeModelIds = peeked.modelIds; opencodeProviders = peeked.providers; - } else { - // No cache yet — auto-probe on first getStatus so free/connected models appear immediately. - const probed = await probeOpenCodeProviderInventory({ - projectRoot, - projectConfig: effectiveConfig, - logger, - discoveredLocalModels, - }); - opencodeInventoryError = probed.error; - opencodeModelIds = probed.modelIds; - opencodeProviders = probed.providers; } } @@ -1338,9 +1325,6 @@ export function createAiIntegrationService(args: { getAvailabilityAsync, resolveModelForTask, - setCompactionFlushService(service: CompactionFlushService | null) { - compactionFlushService = service; - }, // Backward-compatible convenience methods used by migrated services. async generateNarrative(args: { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 616818509..82ff6f258 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -596,6 +596,10 @@ function createMockSessionService() { row.endedAt = args?.endedAt ?? new Date().toISOString(); } }), + deleteSession: vi.fn((sessionId: string) => { + sessions.delete(sessionId); + return true; + }), updateMeta: vi.fn((args: any) => { const row = sessions.get(args.sessionId); if (row) { @@ -944,6 +948,7 @@ describe("createAgentChatService", () => { expect(service.getAvailableModels).toBeTypeOf("function"); expect(service.getSlashCommands).toBeTypeOf("function"); expect(service.dispose).toBeTypeOf("function"); + expect(service.deleteSession).toBeTypeOf("function"); expect(service.disposeAll).toBeTypeOf("function"); expect(service.updateSession).toBeTypeOf("function"); expect(service.warmupModel).toBeTypeOf("function"); @@ -2327,6 +2332,146 @@ describe("createAgentChatService", () => { }); }); + // -------------------------------------------------------------------------- + // compaction flush (issue #153): no visible flush message; PreCompact hook + // -------------------------------------------------------------------------- + + describe("compaction flush", () => { + it("emits context_compact without a user_message carrying the flush prompt", async () => { + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = 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-compact", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "system", + subtype: "compact_boundary", + session_id: "sdk-session-compact", + compact_metadata: { trigger: "auto", pre_tokens: 150_000 }, + }; + yield { + type: "assistant", + session_id: "sdk-session-compact", + message: { + content: [{ type: "text", text: "Continuing after compaction" }], + 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-compact", + setPermissionMode, + } as any); + + const onEvent = vi.fn(); + const { service } = createService({ onEvent }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "keep going", + timeoutMs: 15_000, + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + const compactEvents = onEvent.mock.calls + .map((call) => call[0]) + .filter((env: any) => env?.event?.type === "context_compact"); + expect(compactEvents).toHaveLength(1); + expect(compactEvents[0].event).toMatchObject({ + type: "context_compact", + trigger: "auto", + preTokens: 150_000, + }); + + const leakedUserMessages = onEvent.mock.calls + .map((call) => call[0]) + .filter((env: any) => + env?.event?.type === "user_message" + && typeof env.event.text === "string" + && env.event.text.includes("Before context compaction runs"), + ); + expect(leakedUserMessages).toHaveLength(0); + + // Defence in depth: the pre-fix leak happened when main.ts reacted to + // the context_compact chat event by calling steer(), which pushed the + // flush prompt to the SDK via send(). Assert the SDK never received a + // turn whose payload contains the flush-prompt text, regardless of + // whether the leak originated from the SDK side or a downstream handler. + const flushedSends = send.mock.calls.filter(([payload]) => + typeof payload === "string" + ? payload.includes("Before context compaction runs") + : JSON.stringify(payload ?? "").includes("Before context compaction runs"), + ); + expect(flushedSends).toHaveLength(0); + }); + + it("registers a PreCompact hook on non-lightweight Claude sessions", async () => { + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send: vi.fn(), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-precompact", + } 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 { + hooks?: Record Promise> }>>; + } | undefined; + const matchers = opts?.hooks?.PreCompact; + expect(matchers).toBeDefined(); + expect(matchers!.length).toBeGreaterThan(0); + const callback = matchers![0].hooks[0]; + const result = await callback( + { hook_event_name: "PreCompact", trigger: "auto", custom_instructions: null } as any, + undefined as any, + { signal: new AbortController().signal } as any, + ); + expect(result).toMatchObject({ continue: true }); + expect(typeof (result as { systemMessage?: string }).systemMessage).toBe("string"); + expect((result as { systemMessage: string }).systemMessage).toContain( + "Before context compaction runs", + ); + }); + }); + // -------------------------------------------------------------------------- // getSessionSummary // -------------------------------------------------------------------------- @@ -2865,6 +3010,55 @@ describe("createAgentChatService", () => { }); }); + describe("deleteSession", () => { + it("removes persisted chat artifacts and the stored session row", async () => { + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/anthropic/claude-sonnet-4-6", + }); + + const metadataPath = path.join(tmpRoot, ".ade", "cache", "chat-sessions", `${session.id}.json`); + const dedicatedTranscriptPath = path.join(tmpRoot, ".ade", "transcripts", "chat", `${session.id}.jsonl`); + const mainTranscriptPath = sessionService.get(session.id)?.transcriptPath ?? ""; + + fs.writeFileSync(metadataPath, JSON.stringify({ sessionId: session.id }), "utf8"); + fs.mkdirSync(path.dirname(dedicatedTranscriptPath), { recursive: true }); + fs.writeFileSync(dedicatedTranscriptPath, "{\"event\":\"done\"}\n", "utf8"); + fs.mkdirSync(path.dirname(mainTranscriptPath), { recursive: true }); + fs.writeFileSync(mainTranscriptPath, "{\"event\":\"done\"}\n", "utf8"); + + await service.dispose({ sessionId: session.id }); + await service.deleteSession({ sessionId: session.id }); + + expect(sessionService.deleteSession).toHaveBeenCalledWith(session.id); + expect(sessionService.get(session.id)).toBeNull(); + expect(fs.existsSync(metadataPath)).toBe(false); + expect(fs.existsSync(dedicatedTranscriptPath)).toBe(false); + expect(fs.existsSync(mainTranscriptPath)).toBe(false); + await expect(service.getSessionSummary(session.id)).resolves.toBeNull(); + }); + + it("disposes running chats before purging them", async () => { + const { service, sessionService } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/anthropic/claude-sonnet-4-6", + }); + + await service.deleteSession({ sessionId: session.id }); + + expect(sessionService.end).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: session.id }), + ); + expect(sessionService.deleteSession).toHaveBeenCalledWith(session.id); + }); + }); + // -------------------------------------------------------------------------- // cleanupStaleAttachments // -------------------------------------------------------------------------- @@ -4608,6 +4802,74 @@ describe("createAgentChatService", () => { vi.useRealTimers(); } }); + + it("tears down idle Claude runtimes after the inactivity ttl", async () => { + vi.useFakeTimers(); + try { + const close = vi.fn(); + let streamCall = 0; + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + + const sessionHandle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-idle-ttl", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "assistant", + session_id: "sdk-session-idle-ttl", + message: { + content: [{ type: "text", text: "Done." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close, + sessionId: "sdk-session-idle-ttl", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(sessionHandle as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(sessionHandle as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Say hi", + timeoutMs: 15_000, + }); + + await vi.advanceTimersByTimeAsync(6 * 60_000); + + expect(close).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); }); // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index a1af63518..ff2e421c9 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -56,6 +56,7 @@ import { resolvePathWithinRoot, } from "../shared/utils"; import type { EpisodicSummaryService } from "../memory/episodicSummaryService"; +import { DEFAULT_FLUSH_PROMPT } from "../memory/compactionFlushPrompt"; import { createDefaultComputerUsePolicy, normalizeComputerUsePolicy, @@ -69,6 +70,7 @@ import type { AgentChatCodexConfigSource, AgentChatCodexSandbox, AgentChatCreateArgs, + AgentChatDeleteArgs, AgentChatDisposeArgs, AgentChatEditSteerArgs, AgentChatExecutionMode, @@ -350,6 +352,16 @@ type ClaudeRuntime = { pauseIdleWatchdog?: (() => void) | null; /** Resume the active-turn idle watchdog after the blocking wait finishes. */ resumeIdleWatchdog?: (() => void) | null; + /** + * Set while the SDK is running its auto-compaction flow. During compaction + * the PreCompact hook nudges the model to persist memories via MCP tools, + * which would normally surface an approval prompt. Auto-compaction runs + * without a user present, so we bypass MCP approvals while this is true. + * Reset by a timeout since the SDK does not emit a PostCompact signal. + */ + compactionInProgress?: boolean; + /** Timer used to clear compactionInProgress after a reasonable window. */ + compactionResetTimer?: ReturnType | null; }; type PendingOpenCodeApproval = { @@ -714,6 +726,8 @@ type ManagedChatSession = { preview: string | null; closed: boolean; endedNotified: boolean; + /** Set when deleteSession begins — persistence paths bail to avoid re-creating deleted files. */ + deleted: boolean; ctoSessionStartedAt: string | null; pendingReconstructionContext: string | null; autoTitleSeed: string | null; @@ -3238,10 +3252,15 @@ export function createAgentChatService(args: { // Surface approval prompts for non-bypass permission modes so the user can // allow or deny individual tool calls (matching the opencode runtime pattern). const effectivePermMode = managed.session.claudePermissionMode ?? "default"; - if (claudeToolNeedsApproval(toolName, input, effectivePermMode)) { + const normalizedToolName = normalizeToolNameForApproval(toolName); + // During auto-compaction the PreCompact hook asks the model to persist + // memories via ADE MCP memory tools. No user is present to approve, so + // only those specific tools are auto-allowed — not every MCP tool. + const bypassForCompaction = runtime.compactionInProgress === true + && normalizedToolName.startsWith("mcp_ade_memory_"); + if (!bypassForCompaction && claudeToolNeedsApproval(toolName, input, effectivePermMode)) { // Check session-wide overrides — user already said "Allow for Session" for this tool - const normalizedForOverride = normalizeToolNameForApproval(toolName); - if (runtime.approvalOverrides.has(normalizedForOverride)) { + if (runtime.approvalOverrides.has(normalizedToolName)) { return { behavior: "allow", updatedInput: input }; } @@ -3280,7 +3299,7 @@ export function createAgentChatService(args: { }; emitPendingInputRequest(managed, request, { - kind: normalizedForOverride.includes("bash") ? "command" : "file_change", + kind: normalizedToolName.includes("bash") ? "command" : "file_change", description, detail: { tool: toolName, ...(sdkOptions?.blockedPath ? { blockedPath: sdkOptions.blockedPath } : {}) }, }); @@ -3298,7 +3317,7 @@ export function createAgentChatService(args: { const approved = response.decision === "accept" || response.decision === "accept_for_session"; if (response.decision === "accept_for_session") { - runtime.approvalOverrides.add(normalizedForOverride); + runtime.approvalOverrides.add(normalizedToolName); } if (approved) { return { @@ -4307,6 +4326,7 @@ export function createAgentChatService(args: { managed: ManagedChatSession, args: { stage: "initial" | "final"; latestUserText?: string | null; summary?: string | null } ): Promise => { + if (managed.deleted) return; const config = resolveChatConfig(); if (!config.autoTitleEnabled) return; if (managed.manuallyNamed) return; @@ -4805,7 +4825,47 @@ export function createAgentChatService(args: { const metadataPathFor = (sessionId: string): string => path.join(chatSessionsDir, `${sessionId}.json`); + const deletePersistedChatFile = (filePath: string | null | undefined): void => { + const trimmed = typeof filePath === "string" ? filePath.trim() : ""; + if (!trimmed.length) return; + const resolvedPath = path.resolve(trimmed); + // Resolve symlinks on the target and both roots before comparing, so a + // symlink placed inside the chat dir cannot redirect rmSync outside. + const safeRealpath = (p: string): string | null => { + try { return fs.realpathSync(p); } catch { return null; } + }; + const realTarget = safeRealpath(resolvedPath); + // Missing target is safe to skip — nothing to delete. + if (!realTarget) return; + const realAdeDir = safeRealpath(layout.adeDir); + const realTranscriptRoot = safeRealpath(path.resolve(transcriptsDir)); + const isWithin = (root: string | null): boolean => { + if (!root) return false; + const rel = path.relative(root, realTarget); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + }; + if (!isWithin(realAdeDir) && !isWithin(realTranscriptRoot)) { + logger.warn("agent_chat.delete_skipped_path_outside_ade", { + filePath: resolvedPath, + realTarget, + }); + return; + } + try { + fs.rmSync(realTarget, { force: true }); + } catch (error) { + if (isEnoentError(error)) return; + logger.warn("agent_chat.delete_file_failed", { + filePath: realTarget, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + const persistChatState = (managed: ManagedChatSession): void => { + // Tombstoned sessions (deleted while async work was in flight) must not be + // re-persisted — otherwise the file recreates after deleteSession removed it. + if (managed.deleted) return; // When runtime has been torn down (null) but NOT intentionally invalidated, // fall back to the last persisted state so that provider session ids and // lastLaneDirectiveKey survive a transient teardown (e.g. app backgrounding). @@ -5627,6 +5687,10 @@ export function createAgentChatService(args: { // Mark interrupted so the streaming catch block takes the graceful path managed.runtime.interrupted = true; cancelClaudeWarmup(managed, managed.runtime, "teardown"); + if (managed.runtime.compactionResetTimer) { + clearTimeout(managed.runtime.compactionResetTimer); + managed.runtime.compactionResetTimer = null; + } try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } managed.runtime.v2Session = null; managed.runtime.v2WarmupDone = null; @@ -5724,6 +5788,7 @@ export function createAgentChatService(args: { managed: ManagedChatSession, deterministicSummary: string | null ): Promise => { + if (managed.deleted) return; const config = resolveChatConfig(); if (!config.summaryEnabled) return; if (managed.summaryInFlight) return; @@ -5974,6 +6039,7 @@ export function createAgentChatService(args: { preview: row.lastOutputPreview ?? null, closed: row.status !== "running", endedNotified: row.status !== "running", + deleted: false, ctoSessionStartedAt: row.status === "running" ? row.startedAt : null, pendingReconstructionContext: null, autoTitleSeed: null, @@ -7471,6 +7537,8 @@ export function createAgentChatService(args: { return event.properties.sessionID ?? null; case "command.executed": return event.properties.sessionID; + case "session.compacted": + return event.properties.sessionID; default: return null; } @@ -7480,6 +7548,15 @@ export function createAgentChatService(args: { continue; } + if (event.type === "session.compacted") { + emitChatEvent(managed, { + type: "context_compact", + trigger: "auto", + turnId, + }); + continue; + } + if (event.type === "message.part.updated") { const { part, delta } = event.properties; markFirstStreamEvent(part.type); @@ -8383,6 +8460,20 @@ export function createAgentChatService(args: { return completedTurnId; })(); + if (itemType === "contextCompaction") { + // Codex emits contextCompaction via item/started + item/completed. + // Emit the boundary event once, on completion, so the UI badge matches + // Claude's post-compaction behavior. + if (eventKind === "completed") { + emitChatEvent(managed, { + type: "context_compact", + trigger: "auto", + turnId, + }); + } + return; + } + if (itemType === "commandExecution") { emitChatEvent(managed, { type: "activity", @@ -9519,6 +9610,36 @@ export function createAgentChatService(args: { } opts.canUseTool = buildClaudeCanUseTool(runtime, managed) as any; + // PreCompact hook: nudge the model to save durable discoveries into + // ADE memory before the SDK compacts context. Runs inside the SDK's + // compaction flow so the text never surfaces as a visible user turn. + // + // Mark the runtime as in-compaction so the canUseTool gate auto-allows + // MCP memory tools the model calls in response to the flush prompt. + // Auto-compaction runs unattended; surfacing an approval prompt would + // hang the flow waiting on a user who may not be present. Flag is + // cleared by a timer since the SDK does not emit a PostCompact signal. + (opts as any).hooks = { + PreCompact: [ + { + hooks: [ + async () => { + runtime.compactionInProgress = true; + if (runtime.compactionResetTimer) clearTimeout(runtime.compactionResetTimer); + runtime.compactionResetTimer = setTimeout(() => { + runtime.compactionInProgress = false; + runtime.compactionResetTimer = null; + }, 60_000); + return { + continue: true, + systemMessage: DEFAULT_FLUSH_PROMPT, + }; + }, + ], + }, + ], + }; + // 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 }, @@ -10171,6 +10292,7 @@ export function createAgentChatService(args: { preview: null, closed: false, endedNotified: false, + deleted: false, lastActivitySignature: null, bufferedReasoning: null, ctoSessionStartedAt: null, @@ -10526,6 +10648,7 @@ export function createAgentChatService(args: { preview: null, closed: false, endedNotified: false, + deleted: false, ctoSessionStartedAt: identityKey === "cto" ? startedAt : null, pendingReconstructionContext: null, autoTitleSeed: null, @@ -13102,6 +13225,79 @@ export function createAgentChatService(args: { }); }; + const deleteSession = async ({ sessionId }: AgentChatDeleteArgs): Promise => { + const trimmedSessionId = typeof sessionId === "string" ? sessionId.trim() : ""; + if (!trimmedSessionId.length) { + throw new Error("Chat session id is required."); + } + + const existing = sessionService.get(trimmedSessionId); + if (!existing) { + throw new Error(`Chat session '${trimmedSessionId}' was not found.`); + } + if (!isChatToolType(existing.toolType)) { + throw new Error(`Session '${trimmedSessionId}' is not an agent chat session.`); + } + + // Tombstone the session before any async work so in-flight persistence + // (auto-title, summary, chat state writes) bails instead of recreating files. + // We do NOT set endedNotified here — dispose() still needs to run finishSession + // so sessionService.end fires. + const tombstoned = managedSessions.get(trimmedSessionId); + if (tombstoned) { + tombstoned.deleted = true; + } + + if (existing.status === "running") { + await dispose({ sessionId: trimmedSessionId }); + } + + const current = sessionService.get(trimmedSessionId); + if (!current) return; + + const activeCollector = sessionTurnCollectors.get(trimmedSessionId); + if (activeCollector) { + if (activeCollector.timeout) { + clearTimeout(activeCollector.timeout); + } + sessionTurnCollectors.delete(trimmedSessionId); + activeCollector.reject(new Error(`Chat session '${trimmedSessionId}' was deleted.`)); + } + + const managed = managedSessions.get(trimmedSessionId); + if (managed) { + // Resolve any outstanding input waiters (plan approvals, questions, etc.) + // so callers awaiting them unblock with a cancellation instead of hanging + // forever once the session is gone. + for (const pending of managed.localPendingInputs.values()) { + pending.resolve({ decision: "cancel" }); + } + managed.localPendingInputs.clear(); + managed.deleted = true; + managed.closed = true; + managed.endedNotified = true; + managed.ctoSessionStartedAt = null; + clearSubagentSnapshots(trimmedSessionId); + teardownRuntime(managed, "ended_session"); + managedSessions.delete(trimmedSessionId); + } else { + clearSubagentSnapshots(trimmedSessionId); + } + + const persistedMetadataPath = metadataPathFor(trimmedSessionId); + const dedicatedTranscriptPath = path.join(chatTranscriptsDir, `${trimmedSessionId}.jsonl`); + const transcriptPaths = new Set([ + persistedMetadataPath, + dedicatedTranscriptPath, + current.transcriptPath, + ]); + for (const filePath of transcriptPaths) { + deletePersistedChatFile(filePath); + } + + sessionService.deleteSession(trimmedSessionId); + }; + const disposeAll = async (): Promise => { clearInterval(sessionCleanupTimer); for (const sessionId of [...managedSessions.keys()]) { @@ -13120,8 +13316,8 @@ export function createAgentChatService(args: { if ( managed.runtime && !managed.closed - // Keep Claude sessions warm until the user explicitly ends them. - && managed.runtime.kind !== "claude" + && managed.session.status === "idle" + && !hasLivePendingInput(managed) && now - managed.lastActivityTimestamp > SESSION_INACTIVITY_TIMEOUT_MS ) { teardownRuntime(managed, "idle_ttl"); @@ -13138,9 +13334,8 @@ export function createAgentChatService(args: { for (const [id, managed] of managedSessions) { if (id === excludeSessionId) continue; if (!managed.runtime) continue; - // Claude V2 runtimes keep their in-memory session state and should not be - // evicted behind the user's back. - if (managed.runtime.kind === "claude") continue; + if (managed.session.status !== "idle") continue; + if (hasLivePendingInput(managed)) continue; if (managed.lastActivityTimestamp < oldestTimestamp) { oldestTimestamp = managed.lastActivityTimestamp; oldest = managed; @@ -13897,6 +14092,7 @@ export function createAgentChatService(args: { getSlashCommands, codexFuzzyFileSearch, dispose, + deleteSession, disposeAll, updateSession, warmupModel, diff --git a/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts b/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts index 185e80419..bf82c0661 100644 --- a/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts +++ b/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts @@ -33,118 +33,87 @@ describe("createFeedbackReporterService", () => { vi.clearAllMocks(); }); - it("posts successfully when the model wraps JSON in prose", async () => { + it("prepares a deterministic bug body and uses AI for title and labels when available", async () => { const db = createDb(); const logger = createLogger(); const executeTask = vi.fn(async () => ({ text: [ - "I reviewed the request. Here is the issue:", + "Here is the metadata:", "```json", JSON.stringify({ - title: "Improve failed submission details in feedback reporter", - body: "## Description\n\nShow the saved error in My Submissions.", - labels: ["bug"], + title: "Failed submissions should show the error reason", + labels: ["bug", "documentation"], }), "```", ].join("\n"), structuredOutput: null, })); - const apiRequest = vi.fn(async () => ({ - data: { - html_url: "https://github.com/arul28/ADE/issues/999", - number: 999, - }, - })); const service = createFeedbackReporterService({ db: db as any, logger: logger as any, projectRoot: "/Users/admin/Projects/ADE", aiIntegrationService: { executeTask } as any, - githubService: { apiRequest } as any, + githubService: { apiRequest: vi.fn() } as any, }); - service.submit({ - category: "bug", - userDescription: "The failed submission view should show the saved error.", + const draft = await service.prepareDraft({ modelId: "anthropic/claude-opus-4-6", + draftInput: { + category: "bug", + summary: "Failed submissions should show the error reason.", + stepsToReproduce: "1. Submit a report\n2. Force GitHub failure", + expectedBehavior: "Show the saved error.", + actualBehavior: "Only a failed badge is visible.", + environment: "ADE Desktop on macOS", + additionalContext: "The issue is easier to debug when the saved payload is visible.", + }, }); - await vi.waitFor(() => { - expect(apiRequest).toHaveBeenCalledTimes(1); - }); - - const [submission] = service.list(); - expect(submission?.status).toBe("posted"); - expect(submission?.generatedTitle).toBe("Improve failed submission details in feedback reporter"); - expect(submission?.generatedBody).toContain("Show the saved error"); - expect(submission?.issueNumber).toBe(999); - expect(logger.warn).not.toHaveBeenCalledWith("feedback.generated_with_fallback", expect.anything()); + expect(draft.generationMode).toBe("ai_assisted"); + expect(draft.generationWarning).toBeNull(); + expect(draft.title).toBe("Failed submissions should show the error reason"); + expect(draft.labels).toEqual(["bug", "documentation"]); + expect(draft.body).toContain("## Steps to Reproduce"); + expect(draft.body).toContain("Only a failed badge is visible."); + expect(draft.body).toContain("## Additional Context"); }); - it("falls back to a deterministic issue draft when the model output is unusable", async () => { + it("prepares a deterministic draft when no AI model is selected", async () => { const db = createDb(); const logger = createLogger(); - const executeTask = vi.fn(async () => ({ - text: "I could not comply with the requested format, but the report seems valid.", - structuredOutput: null, - })); - const apiRequest = vi.fn(async () => ({ - data: { - html_url: "https://github.com/arul28/ADE/issues/1000", - number: 1000, - }, - })); + const executeTask = vi.fn(); const service = createFeedbackReporterService({ db: db as any, logger: logger as any, projectRoot: "/Users/admin/Projects/ADE", aiIntegrationService: { executeTask } as any, - githubService: { apiRequest } as any, - }); - - service.submit({ - category: "enhancement", - userDescription: "The previous submissions tab should expand each report and show the original text.", - modelId: "anthropic/claude-opus-4-6", + githubService: { apiRequest: vi.fn() } as any, }); - await vi.waitFor(() => { - expect(apiRequest).toHaveBeenCalledTimes(1); + const draft = await service.prepareDraft({ + draftInput: { + category: "enhancement", + summary: "Make previous submissions expandable.", + useCase: "Inspect what was posted and why it failed.", + proposedSolution: "Show a preview panel before posting.", + alternativesConsidered: "", + additionalContext: "", + }, }); - const [submission] = service.list(); - expect(submission?.status).toBe("posted"); - expect(submission?.generatedTitle).toContain("previous submissions tab"); - expect(submission?.generatedBody).toContain("## Description"); - expect(submission?.generatedBody).toContain("## Proposed Solution"); - const request = (apiRequest as any).mock.calls[0]?.[0] as { body?: { labels?: string[] } } | undefined; - expect(request?.body?.labels).toEqual(["enhancement"]); - expect(logger.warn).toHaveBeenCalledWith( - "feedback.generated_with_fallback", - expect.objectContaining({ - category: "enhancement", - modelId: "anthropic/claude-opus-4-6", - }), - ); + expect(executeTask).not.toHaveBeenCalled(); + expect(draft.generationMode).toBe("deterministic"); + expect(draft.generationWarning).toContain("no AI model was selected"); + expect(draft.title).toBe("Make previous submissions expandable."); + expect(draft.labels).toEqual(["enhancement"]); + expect(draft.body).toContain("## Proposed Solution"); }); - it("stores a stage-specific error when GitHub posting fails", async () => { + it("stores a failed submission when GitHub posting fails", async () => { const db = createDb(); const logger = createLogger(); - const executeTask = vi.fn(async () => ({ - text: JSON.stringify({ - title: "Improve feedback reporter determinism", - body: "## Description\n\nDeterministic fallback formatting.", - labels: ["bug"], - }), - structuredOutput: { - title: "Improve feedback reporter determinism", - body: "## Description\n\nDeterministic fallback formatting.", - labels: ["bug"], - }, - })); const apiRequest = vi.fn(async () => { throw new Error("GitHub API unavailable"); }); @@ -153,24 +122,40 @@ describe("createFeedbackReporterService", () => { db: db as any, logger: logger as any, projectRoot: "/Users/admin/Projects/ADE", - aiIntegrationService: { executeTask } as any, + aiIntegrationService: { executeTask: vi.fn() } as any, githubService: { apiRequest } as any, }); - service.submit({ - category: "bug", - userDescription: "Posting should preserve the generated content if GitHub fails.", - modelId: "anthropic/claude-opus-4-6", - }); - - await vi.waitFor(() => { - const [submission] = service.list(); - expect(submission?.status).toBe("failed"); + const submission = await service.submitPreparedDraft({ + draft: { + category: "bug", + draftInput: { + category: "bug", + summary: "Posting should preserve the prepared draft.", + stepsToReproduce: "1. Prepare draft", + expectedBehavior: "Keep the reviewed draft when post fails.", + actualBehavior: "GitHub rejects the request.", + environment: "ADE Desktop", + additionalContext: "", + }, + userDescription: "## Summary\n\nPosting should preserve the prepared draft.", + modelId: "anthropic/claude-opus-4-6", + reasoningEffort: null, + title: "Preserve reviewed drafts when GitHub posting fails", + body: "## Description\n\nDeterministic body.", + labels: ["bug"], + generationMode: "ai_assisted", + generationWarning: null, + }, + title: "Preserve reviewed drafts when GitHub posting fails", + body: "## Description\n\nDeterministic body.", + labels: ["bug"], }); - const [submission] = service.list(); - expect(submission?.generatedTitle).toBe("Improve feedback reporter determinism"); - expect(submission?.error).toBe("Posting failed: GitHub API unavailable"); + expect(submission.generatedTitle).toBe("Preserve reviewed drafts when GitHub posting fails"); + expect(submission.generationMode).toBe("ai_assisted"); + expect(submission.status).toBe("failed"); + expect(submission.error).toBe("Posting failed: GitHub API unavailable"); expect(logger.error).toHaveBeenCalledWith( "feedback.failed", expect.objectContaining({ @@ -178,4 +163,55 @@ describe("createFeedbackReporterService", () => { }), ); }); + + it("stores a posted submission after a reviewed draft is submitted", async () => { + const db = createDb(); + const logger = createLogger(); + const apiRequest = vi.fn(async () => ({ + data: { + html_url: "https://github.com/arul28/ADE/issues/999", + number: 999, + }, + })); + + const service = createFeedbackReporterService({ + db: db as any, + logger: logger as any, + projectRoot: "/Users/admin/Projects/ADE", + aiIntegrationService: { executeTask: vi.fn() } as any, + githubService: { apiRequest } as any, + }); + + const submission = await service.submitPreparedDraft({ + draft: { + category: "question", + draftInput: { + category: "question", + summary: "Clarify what happens when feedback posting fails.", + context: "Users currently only see a failed badge.", + expectedGuidance: "Explain the failure and preserve the draft.", + additionalContext: "", + }, + userDescription: "## Summary\n\nClarify what happens when feedback posting fails.", + modelId: null, + reasoningEffort: null, + title: "Clarify failed feedback submission behavior", + body: "## Description\n\nExplain the failure state.", + labels: ["question"], + generationMode: "deterministic", + generationWarning: "ADE used a deterministic draft because no AI model was selected.", + }, + title: "Clarify failed feedback submission behavior", + body: "## Description\n\nExplain the failure state.", + labels: ["question"], + }); + + expect(submission.status).toBe("posted"); + expect(submission.issueNumber).toBe(999); + expect(submission.modelId).toBeNull(); + expect(logger.info).toHaveBeenCalledWith( + "feedback.posted", + expect.objectContaining({ issueNumber: 999 }), + ); + }); }); diff --git a/apps/desktop/src/main/services/feedback/feedbackReporterService.ts b/apps/desktop/src/main/services/feedback/feedbackReporterService.ts index 7d8031170..53cc4f3e9 100644 --- a/apps/desktop/src/main/services/feedback/feedbackReporterService.ts +++ b/apps/desktop/src/main/services/feedback/feedbackReporterService.ts @@ -7,9 +7,14 @@ import type { createAiIntegrationService } from "../ai/aiIntegrationService"; import type { createGithubService } from "../github/githubService"; import { parseStructuredOutput } from "../ai/utils"; import type { + FeedbackCategory, + FeedbackDraftInput, + FeedbackGenerationMode, + FeedbackPreparedDraft, + FeedbackPrepareDraftArgs, FeedbackSubmission, - FeedbackSubmitArgs, FeedbackSubmissionEvent, + FeedbackSubmitDraftArgs, } from "../../../shared/types/feedback"; const DB_KEY = "feedback:submissions"; @@ -17,24 +22,27 @@ const ALLOWED_LABELS = new Set([ "bug", "enhancement", "question", "documentation", "good first issue", "help wanted", "invalid", "wontfix", ]); -const FEEDBACK_ISSUE_JSON_SCHEMA = { +const FEEDBACK_METADATA_JSON_SCHEMA = { type: "object", additionalProperties: false, properties: { title: { type: "string" }, - body: { type: "string" }, labels: { type: "array", items: { type: "string" }, }, }, - required: ["title", "body", "labels"], + required: ["title", "labels"], } as const; -type FeedbackIssueDraft = { +const DETERMINISTIC_NO_MODEL_WARNING = "ADE used a deterministic draft because no AI model was selected. Review the generated title and labels before posting."; +const DETERMINISTIC_FORMAT_WARNING = "ADE used a deterministic draft because the AI title and label suggestion did not match the expected structured format."; + +type FeedbackMetadataSuggestion = { title: string; - body: string; labels: string[]; + generationMode: FeedbackGenerationMode; + generationWarning: string | null; }; function nowIso(): string { @@ -49,6 +57,10 @@ function normalizeWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function normalizeMultiline(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + function clampText(value: string, maxLength: number): string { const trimmed = normalizeWhitespace(value); if (trimmed.length <= maxLength) return trimmed; @@ -60,7 +72,7 @@ function titleCaseFirst(value: string): string { return value[0]!.toUpperCase() + value.slice(1); } -function defaultLabelsForCategory(category: FeedbackSubmission["category"]): string[] { +function defaultLabelsForCategory(category: FeedbackCategory): string[] { switch (category) { case "bug": return ["bug"]; @@ -72,10 +84,7 @@ function defaultLabelsForCategory(category: FeedbackSubmission["category"]): str } } -function normalizeLabels( - category: FeedbackSubmission["category"], - labels: unknown, -): string[] { +function normalizeLabels(category: FeedbackCategory, labels: unknown): string[] { const normalized = Array.isArray(labels) ? labels .map((value) => String(value ?? "").trim().toLowerCase()) @@ -85,104 +94,156 @@ function normalizeLabels( return Array.from(new Set(combined)); } -function fallbackTitle(submission: FeedbackSubmission): string { - const firstLine = submission.userDescription - .split(/\r?\n/) - .map((line) => normalizeWhitespace(line)) - .find((line) => line.length > 0); - const candidate = firstLine && firstLine.length > 0 - ? firstLine - : `${submission.category} report`; - return titleCaseFirst(clampText(candidate, 90)); +type SectionHeadings = { + summary: string; + bug: { steps: string; expected: string; actual: string; environment: string }; + feature: { useCase: string; proposed: string; alternatives: string }; + question: { context: string; guidance: string }; + additional: string; +}; + +const USER_DESCRIPTION_HEADINGS: SectionHeadings = { + summary: "Summary", + bug: { + steps: "Steps to reproduce", + expected: "Expected behavior", + actual: "Actual behavior", + environment: "Environment", + }, + feature: { + useCase: "Use case", + proposed: "Proposed solution", + alternatives: "Alternatives considered", + }, + question: { + context: "Context", + guidance: "Expected guidance", + }, + additional: "Additional context", +}; + +const ISSUE_BODY_HEADINGS: SectionHeadings = { + summary: "Description", + bug: { + steps: "Steps to Reproduce", + expected: "Expected Behavior", + actual: "Actual Behavior", + environment: "Environment", + }, + feature: { + useCase: "Use Case", + proposed: "Proposed Solution", + alternatives: "Alternatives Considered", + }, + question: { + context: "Context", + guidance: "Expected Guidance", + }, + additional: "Additional Context", +}; + +function appendSection(lines: string[], heading: string, value: string): void { + lines.push(`## ${heading}`, "", value.length > 0 ? value : "Not provided.", ""); } -function fallbackBody(submission: FeedbackSubmission): string { - const description = submission.userDescription.trim(); - switch (submission.category) { +function normalizeDraftInput(input: FeedbackDraftInput): FeedbackDraftInput { + const summary = normalizeMultiline(input.summary); + const additionalContext = normalizeMultiline(input.additionalContext); + switch (input.category) { case "bug": - return [ - "## Description", - "", - description, - "", - "## Steps to Reproduce", - "", - "Not provided.", - "", - "## Expected Behavior", - "", - "Not provided.", - "", - "## Actual Behavior", - "", - "Not provided.", - "", - "## Environment", - "", - "- App: ADE Desktop", - `- Model: ${submission.modelId}`, - ].join("\n"); + return { + category: "bug", + summary, + stepsToReproduce: normalizeMultiline(input.stepsToReproduce), + expectedBehavior: normalizeMultiline(input.expectedBehavior), + actualBehavior: normalizeMultiline(input.actualBehavior), + environment: normalizeMultiline(input.environment), + additionalContext, + }; + case "feature": + case "enhancement": + return { + category: input.category, + summary, + useCase: normalizeMultiline(input.useCase), + proposedSolution: normalizeMultiline(input.proposedSolution), + alternativesConsidered: normalizeMultiline(input.alternativesConsidered), + additionalContext, + }; case "question": - return [ - "## Description", - "", - description, - "", - "## Context", - "", - "Not provided.", - "", - "## Expected Guidance", - "", - "Not provided.", - ].join("\n"); + return { + category: "question", + summary, + context: normalizeMultiline(input.context), + expectedGuidance: normalizeMultiline(input.expectedGuidance), + additionalContext, + }; + } +} + +function renderSections(input: FeedbackDraftInput, headings: SectionHeadings): string { + const lines: string[] = []; + appendSection(lines, headings.summary, input.summary); + switch (input.category) { + case "bug": + appendSection(lines, headings.bug.steps, normalizeMultiline(input.stepsToReproduce)); + appendSection(lines, headings.bug.expected, normalizeMultiline(input.expectedBehavior)); + appendSection(lines, headings.bug.actual, normalizeMultiline(input.actualBehavior)); + appendSection(lines, headings.bug.environment, normalizeMultiline(input.environment)); + break; case "feature": case "enhancement": - return [ - "## Description", - "", - description, - "", - "## Use Case", - "", - "Not provided.", - "", - "## Proposed Solution", - "", - "Not provided.", - "", - "## Alternatives Considered", - "", - "Not provided.", - ].join("\n"); + appendSection(lines, headings.feature.useCase, normalizeMultiline(input.useCase)); + appendSection(lines, headings.feature.proposed, normalizeMultiline(input.proposedSolution)); + appendSection(lines, headings.feature.alternatives, normalizeMultiline(input.alternativesConsidered)); + break; + case "question": + appendSection(lines, headings.question.context, normalizeMultiline(input.context)); + appendSection(lines, headings.question.guidance, normalizeMultiline(input.expectedGuidance)); + break; + } + const additional = normalizeMultiline(input.additionalContext); + if (additional.length > 0) { + appendSection(lines, headings.additional, additional); } + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + return lines.join("\n"); } -function normalizeIssueDraft( - submission: FeedbackSubmission, - structuredOutput: unknown, -): { draft: FeedbackIssueDraft; usedFallback: boolean } { - const candidate = isRecord(structuredOutput) ? structuredOutput : null; - const title = - typeof candidate?.title === "string" && candidate.title.trim().length > 0 - ? candidate.title.trim() - : fallbackTitle(submission); - const body = - typeof candidate?.body === "string" && candidate.body.trim().length > 0 - ? candidate.body.trim() - : fallbackBody(submission); - const labels = normalizeLabels(submission.category, candidate?.labels); - const usedFallback = candidate == null - || title === fallbackTitle(submission) - || body === fallbackBody(submission); +function buildUserDescription(input: FeedbackDraftInput): string { + return renderSections(input, USER_DESCRIPTION_HEADINGS); +} + +function buildIssueBody(input: FeedbackDraftInput): string { + return renderSections(input, ISSUE_BODY_HEADINGS); +} + +function fallbackTitleForInput(input: FeedbackDraftInput): string { + const candidate = input.summary.length > 0 ? input.summary : `${input.category} report`; + return titleCaseFirst(clampText(candidate, 90)); +} + +function normalizeGenerationWarning(value: unknown): string | null { + const warning = typeof value === "string" ? value.trim() : ""; + return warning.length > 0 ? warning : null; +} + +function normalizeGenerationMode(value: unknown): FeedbackGenerationMode | null { + if (value === "ai_assisted" || value === "deterministic") return value; + if (value === "ai_structured") return "ai_assisted"; + if (value === "fallback_template") return "deterministic"; + return null; +} +function normalizeStoredSubmission(submission: FeedbackSubmission): FeedbackSubmission { return { - draft: { - title, - body, - labels, - }, - usedFallback, + ...submission, + modelId: pickTrimmed(submission.modelId), + reasoningEffort: submission.reasoningEffort ?? null, + generationMode: normalizeGenerationMode(submission.generationMode), + generationWarning: normalizeGenerationWarning(submission.generationWarning), }; } @@ -196,41 +257,84 @@ function emitUpdate(submission: FeedbackSubmission): void { } } -const SYSTEM_PROMPT_BUG = `You are a GitHub issue writer for the ADE open-source project (github.com/arul28/ADE). -The user is reporting a bug. Generate a well-structured GitHub issue. - -Use this format: -- Title: a concise summary of the bug -- Body (GitHub-flavored markdown): - ## Description - ## Steps to Reproduce - ## Expected Behavior - ## Actual Behavior - ## Environment - -Apply appropriate labels from: bug, enhancement, question, documentation, good first issue, help wanted, invalid, wontfix. -For bug reports, always include the "bug" label. - -Respond with ONLY valid JSON (no markdown fences): { "title": string, "body": string, "labels": string[] }`; - -const SYSTEM_PROMPT_FEATURE = `You are a GitHub issue writer for the ADE open-source project (github.com/arul28/ADE). -The user is requesting a feature or enhancement. Generate a well-structured GitHub issue. - -Use this format: -- Title: a concise summary of the request -- Body (GitHub-flavored markdown): - ## Description - ## Use Case - ## Proposed Solution - ## Alternatives Considered +const METADATA_SYSTEM_PROMPT = `You help convert structured ADE feedback into GitHub issue metadata. + +Return ONLY valid JSON with: +- title: a concise GitHub issue title +- labels: an array of allowed GitHub labels + +Allowed labels: bug, enhancement, question, documentation, good first issue, help wanted, invalid, wontfix. +Use only details present in the provided structured fields and deterministic issue body. Do not invent missing sections or behavior.`; + +function buildMetadataPrompt(input: FeedbackDraftInput, body: string): string { + return [ + `Category: ${input.category}`, + "", + "Structured input:", + JSON.stringify(input, null, 2), + "", + "Deterministic issue body:", + body, + "", + "Suggest the best title and labels for this issue.", + ].join("\n"); +} -Apply appropriate labels from: bug, enhancement, question, documentation, good first issue, help wanted, invalid, wontfix. -For features use "enhancement". For questions use "question". +function normalizeMetadataSuggestion( + category: FeedbackCategory, + fallbackTitle: string, + structuredOutput: unknown, +): FeedbackMetadataSuggestion { + const candidate = isRecord(structuredOutput) ? structuredOutput : null; + const aiTitle = typeof candidate?.title === "string" ? candidate.title.trim() : ""; + if (aiTitle.length === 0) { + return { + title: fallbackTitle, + labels: defaultLabelsForCategory(category), + generationMode: "deterministic", + generationWarning: DETERMINISTIC_FORMAT_WARNING, + }; + } + return { + title: aiTitle, + labels: normalizeLabels(category, candidate?.labels), + generationMode: "ai_assisted", + generationWarning: null, + }; +} -Respond with ONLY valid JSON (no markdown fences): { "title": string, "body": string, "labels": string[] }`; +function pickTrimmed(...candidates: (string | null | undefined)[]): string | null { + for (const candidate of candidates) { + if (typeof candidate !== "string") continue; + const trimmed = candidate.trim(); + if (trimmed.length > 0) return trimmed; + } + return null; +} -function systemPromptForCategory(category: FeedbackSubmission["category"]): string { - return category === "bug" ? SYSTEM_PROMPT_BUG : SYSTEM_PROMPT_FEATURE; +function normalizePreparedDraft( + draft: FeedbackPreparedDraft, + overrides?: { title?: string; body?: string; labels?: string[] }, +): FeedbackPreparedDraft { + const draftInput = normalizeDraftInput(draft.draftInput); + const category = draftInput.category; + const fallbackTitle = fallbackTitleForInput(draftInput); + const userDescription = pickTrimmed(draft.userDescription) ?? buildUserDescription(draftInput); + const title = pickTrimmed(overrides?.title, draft.title) ?? fallbackTitle; + const body = pickTrimmed(overrides?.body, draft.body) ?? buildIssueBody(draftInput); + const labels = normalizeLabels(category, overrides?.labels ?? draft.labels); + return { + category, + draftInput, + userDescription, + modelId: pickTrimmed(draft.modelId), + reasoningEffort: draft.reasoningEffort ?? null, + title, + body, + labels, + generationMode: normalizeGenerationMode(draft.generationMode) ?? "deterministic", + generationWarning: normalizeGenerationWarning(draft.generationWarning), + }; } export function createFeedbackReporterService({ @@ -247,12 +351,12 @@ export function createFeedbackReporterService({ githubService: ReturnType; }) { function loadAll(): FeedbackSubmission[] { - return db.getJson(DB_KEY) ?? []; + return (db.getJson(DB_KEY) ?? []).map(normalizeStoredSubmission); } function save(submission: FeedbackSubmission): void { const all = loadAll(); - const idx = all.findIndex((s) => s.id === submission.id); + const idx = all.findIndex((entry) => entry.id === submission.id); if (idx >= 0) { all[idx] = submission; } else { @@ -261,120 +365,93 @@ export function createFeedbackReporterService({ db.setJson(DB_KEY, all); } - async function runSubmission(submission: FeedbackSubmission): Promise { - try { - // -- Generate -- - submission.status = "generating"; - save(submission); - emitUpdate(submission); - - let normalizedDraft: FeedbackIssueDraft; + async function prepareDraft(args: FeedbackPrepareDraftArgs): Promise { + const draftInput = normalizeDraftInput(args.draftInput); + const category = draftInput.category; + const body = buildIssueBody(draftInput); + const userDescription = buildUserDescription(draftInput); + const fallbackTitle = fallbackTitleForInput(draftInput); + const modelId = pickTrimmed(args.modelId); + const reasoningEffort = args.reasoningEffort ?? null; + + let title = fallbackTitle; + let labels = defaultLabelsForCategory(category); + let generationMode: FeedbackGenerationMode = "deterministic"; + let generationWarning: string | null = modelId ? DETERMINISTIC_FORMAT_WARNING : DETERMINISTIC_NO_MODEL_WARNING; + + if (modelId) { try { const result = await aiIntegrationService.executeTask({ feature: "pr_descriptions", taskType: "pr_description", - prompt: `Category: ${submission.category}\n\nUser description:\n${submission.userDescription}`, - systemPrompt: systemPromptForCategory(submission.category), + prompt: buildMetadataPrompt(draftInput, body), + systemPrompt: METADATA_SYSTEM_PROMPT, cwd: projectRoot, - model: submission.modelId, - jsonSchema: FEEDBACK_ISSUE_JSON_SCHEMA, + model: modelId, + jsonSchema: FEEDBACK_METADATA_JSON_SCHEMA, permissionMode: "read-only", oneShot: true, - timeoutMs: 300_000, - ...(submission.reasoningEffort ? { reasoningEffort: submission.reasoningEffort } : {}), + timeoutMs: 120_000, + ...(reasoningEffort ? { reasoningEffort } : {}), }); - - const structuredCandidate = result.structuredOutput ?? parseStructuredOutput(result.text); - const normalized = normalizeIssueDraft(submission, structuredCandidate); - normalizedDraft = normalized.draft; - - if (normalized.usedFallback) { - logger.warn("feedback.generated_with_fallback", { - id: submission.id, - category: submission.category, - modelId: submission.modelId, + const suggestion = normalizeMetadataSuggestion( + category, + fallbackTitle, + result.structuredOutput ?? parseStructuredOutput(result.text), + ); + title = suggestion.title; + labels = suggestion.labels; + generationMode = suggestion.generationMode; + generationWarning = suggestion.generationWarning; + if (suggestion.generationMode === "deterministic") { + logger.warn("feedback.draft_generated_deterministically", { + category, + modelId, }); } - - submission.generatedTitle = normalized.draft.title; - submission.generatedBody = normalized.draft.body; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - logger.warn("feedback.generation_failed_using_fallback", { - id: submission.id, - category: submission.category, - modelId: submission.modelId, + logger.warn("feedback.draft_ai_failed_using_deterministic", { + category, + modelId, error: message, }); - normalizedDraft = { - title: fallbackTitle(submission), - body: fallbackBody(submission), - labels: defaultLabelsForCategory(submission.category), - }; - submission.generatedTitle = normalizedDraft.title; - submission.generatedBody = normalizedDraft.body; + generationWarning = `ADE used a deterministic draft because AI title and label suggestion failed: ${message}`; } - - // -- Post to GitHub -- - submission.status = "posting"; - save(submission); - emitUpdate(submission); - - try { - const { data } = await githubService.apiRequest<{ - html_url: string; - number: number; - }>({ - method: "POST", - path: "/repos/arul28/ADE/issues", - body: { - title: normalizedDraft.title, - body: normalizedDraft.body, - labels: normalizedDraft.labels, - }, - }); - - submission.issueUrl = data.html_url; - submission.issueNumber = data.number; - submission.issueState = "open"; - submission.status = "posted"; - submission.completedAt = nowIso(); - save(submission); - emitUpdate(submission); - - logger.info("feedback.posted", { - id: submission.id, - issueNumber: data.number, - }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Posting failed: ${message}`); - } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - submission.status = "failed"; - submission.error = message; - submission.completedAt = nowIso(); - save(submission); - emitUpdate(submission); - - logger.error("feedback.failed", { - id: submission.id, - error: message, - }); } + + return { + category, + draftInput, + userDescription, + modelId, + reasoningEffort, + title, + body, + labels, + generationMode, + generationWarning, + }; } - function submit(args: FeedbackSubmitArgs): FeedbackSubmission { + async function submitPreparedDraft(args: FeedbackSubmitDraftArgs): Promise { + const prepared = normalizePreparedDraft(args.draft, { + title: args.title, + body: args.body, + labels: args.labels, + }); + const submission: FeedbackSubmission = { id: randomUUID(), - category: args.category, - userDescription: args.userDescription, - modelId: args.modelId, - reasoningEffort: args.reasoningEffort ?? null, - status: "pending", - generatedTitle: null, - generatedBody: null, + category: prepared.category, + userDescription: prepared.userDescription, + modelId: prepared.modelId, + reasoningEffort: prepared.reasoningEffort ?? null, + status: "posting", + generationMode: prepared.generationMode, + generationWarning: prepared.generationWarning, + generatedTitle: prepared.title, + generatedBody: prepared.body, issueUrl: null, issueNumber: null, issueState: null, @@ -386,10 +463,45 @@ export function createFeedbackReporterService({ save(submission); emitUpdate(submission); - // Run generation + posting in the background - runSubmission(submission).catch(() => { - // Error already handled inside runSubmission - }); + try { + const { data } = await githubService.apiRequest<{ + html_url: string; + number: number; + }>({ + method: "POST", + path: "/repos/arul28/ADE/issues", + body: { + title: prepared.title, + body: prepared.body, + labels: prepared.labels, + }, + }); + + submission.issueUrl = data.html_url; + submission.issueNumber = data.number; + submission.issueState = "open"; + submission.status = "posted"; + submission.completedAt = nowIso(); + save(submission); + emitUpdate(submission); + + logger.info("feedback.posted", { + id: submission.id, + issueNumber: data.number, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + submission.status = "failed"; + submission.error = `Posting failed: ${message}`; + submission.completedAt = nowIso(); + save(submission); + emitUpdate(submission); + + logger.error("feedback.failed", { + id: submission.id, + error: submission.error, + }); + } return submission; } @@ -400,5 +512,9 @@ export function createFeedbackReporterService({ ); } - return { submit, list }; + return { + prepareDraft, + submitPreparedDraft, + list, + }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index f1fd44655..68d27328d 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -153,6 +153,11 @@ import type { QueueLandingState, ReplyToPrReviewThreadArgs, ResolvePrReviewThreadArgs, + PostPrReviewCommentArgs, + SetPrReviewThreadResolvedArgs, + ReactToPrCommentArgs, + LaunchPrIssueResolutionFromThreadArgs, + LaunchPrIssueResolutionFromThreadResult, SimulateIntegrationArgs, UpdatePrDescriptionArgs, LandPrArgs, @@ -167,6 +172,7 @@ import type { AgentChatApproveArgs, AgentChatClaudePermissionMode, AgentChatCreateArgs, + AgentChatDeleteArgs, AgentChatDisposeArgs, AgentChatGetSummaryArgs, AgentChatHandoffArgs, @@ -211,6 +217,7 @@ import type { ListLanesArgs, ListMissionsArgs, ListSessionsArgs, + DeleteSessionArgs, ListTestRunsArgs, MergeSimulationArgs, MergeSimulationResult, @@ -479,8 +486,10 @@ import type { GenerateRedirectUrisArgs, EncodeOAuthStateArgs, DecodeOAuthStateArgs, - FeedbackSubmitArgs, + FeedbackPrepareDraftArgs, + FeedbackPreparedDraft, FeedbackSubmission, + FeedbackSubmitDraftArgs, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; @@ -512,6 +521,7 @@ import type { createPrService } from "../prs/prService"; import type { createPrPollingService } from "../prs/prPollingService"; import type { createQueueLandingService } from "../prs/queueLandingService"; import type { createIssueInventoryService } from "../prs/issueInventoryService"; +import type { createPrSummaryService } from "../prs/prSummaryService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { @@ -608,6 +618,7 @@ export type AppContext = { prPollingService: ReturnType; queueLandingService: ReturnType; issueInventoryService: ReturnType; + prSummaryService: ReturnType; jobEngine: ReturnType; automationService: ReturnType; automationPlannerService: ReturnType; @@ -1455,6 +1466,29 @@ function getAllowedDirs(getCtx: () => AppContext): string[] { ]; } +function buildIssueResolutionInstructionsFromThread(arg: LaunchPrIssueResolutionFromThreadArgs): string { + const lines: string[] = [ + `Focus on review thread ${arg.threadId} on PR ${arg.prId}.`, + ]; + if (arg.commentId) { + lines.push(`The relevant comment id is ${arg.commentId}.`); + } + const fileContext = arg.fileContext; + if (fileContext?.path) { + const lineNumber = fileContext.startLine ?? fileContext.line ?? null; + lines.push( + lineNumber != null + ? `Start by inspecting ${fileContext.path}:${lineNumber}.` + : `Start by inspecting ${fileContext.path}.`, + ); + } + if (arg.additionalInstructions) { + lines.push(""); + lines.push(arg.additionalInstructions); + } + return lines.join("\n"); +} + export function registerIpc({ getCtx, switchProjectFromDialog, @@ -3963,6 +3997,25 @@ export function registerIpc({ }; }); + ipcMain.handle(IPC.sessionsDelete, async (_event, arg: DeleteSessionArgs): Promise => { + const ctx = getCtx(); + const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; + if (!sessionId) { + throw new Error("Session id is required."); + } + const session = ctx.sessionService.get(sessionId); + if (!session) { + throw new Error(`Session '${sessionId}' was not found.`); + } + if (isChatToolType(session.toolType)) { + throw new Error(`Session '${sessionId}' is an agent chat session. Use the chat delete flow instead.`); + } + if (session.status === "running" || session.ptyId) { + throw new Error("Running terminal sessions must be closed before they can be deleted."); + } + ctx.sessionService.deleteSession(sessionId); + }); + ipcMain.handle(IPC.sessionsUpdateMeta, async (_event, arg: UpdateSessionMetaArgs): Promise => { const ctx = getCtx(); return ctx.sessionService.updateMeta(arg); @@ -4056,6 +4109,11 @@ export function registerIpc({ await ctx.agentChatService.dispose(arg); }); + ipcMain.handle(IPC.agentChatDelete, async (_event, arg: AgentChatDeleteArgs): Promise => { + const ctx = getCtx(); + await ctx.agentChatService.deleteSession(arg); + }); + ipcMain.handle(IPC.agentChatUpdateSession, async (_event, arg: AgentChatUpdateSessionArgs): Promise => { const ctx = getCtx(); return await ctx.agentChatService.updateSession(arg); @@ -4669,10 +4727,16 @@ export function registerIpc({ }); // ── Feedback Reporter ────────────────────────────────────────────── - ipcMain.handle(IPC.feedbackSubmit, async (_event, arg: FeedbackSubmitArgs): Promise => { + ipcMain.handle(IPC.feedbackPrepareDraft, async (_event, arg: FeedbackPrepareDraftArgs): Promise => { const ctx = getCtx(); if (!ctx.feedbackReporterService) throw new Error("Feedback reporter not available"); - return await ctx.feedbackReporterService.submit(arg); + return await ctx.feedbackReporterService.prepareDraft(arg); + }); + + ipcMain.handle(IPC.feedbackSubmitDraft, async (_event, arg: FeedbackSubmitDraftArgs): Promise => { + const ctx = getCtx(); + if (!ctx.feedbackReporterService) throw new Error("Feedback reporter not available"); + return await ctx.feedbackReporterService.submitPreparedDraft(arg); }); ipcMain.handle(IPC.feedbackList, async (): Promise => { @@ -5310,6 +5374,44 @@ export function registerIpc({ ipcMain.handle(IPC.prsRerunChecks, (_e, args) => getCtx().prService.rerunChecks(args)); ipcMain.handle(IPC.prsAiReviewSummary, (_e, args) => getCtx().prService.aiReviewSummary(args)); + // PRs Tab redesign (Timeline + Rails) + ipcMain.handle(IPC.prsGetDeployments, (_e, args: { prId: string }) => getCtx().prService.getDeployments(args.prId)); + ipcMain.handle(IPC.prsGetAiSummary, (_e, args: { prId: string }) => getCtx().prSummaryService.getSummary(args.prId)); + ipcMain.handle(IPC.prsRegenerateAiSummary, (_e, args: { prId: string }) => getCtx().prSummaryService.regenerateSummary(args.prId)); + ipcMain.handle(IPC.prsPostReviewComment, (_e, args: PostPrReviewCommentArgs) => getCtx().prService.postReviewComment(args)); + ipcMain.handle( + IPC.prsSetReviewThreadResolved, + (_e, args: SetPrReviewThreadResolvedArgs) => getCtx().prService.setReviewThreadResolved(args), + ); + ipcMain.handle(IPC.prsReactToComment, (_e, args: ReactToPrCommentArgs) => getCtx().prService.reactToComment(args)); + ipcMain.handle( + IPC.prsLaunchIssueResolutionFromThread, + async (_e, arg: LaunchPrIssueResolutionFromThreadArgs): Promise => { + const ctx = getCtx(); + const additionalInstructions = buildIssueResolutionInstructionsFromThread(arg); + if (!arg.modelId) { + throw new Error("modelId is required for prsLaunchIssueResolutionFromThread."); + } + return await launchPrIssueResolutionChat( + { + prService: ctx.prService, + laneService: ctx.laneService, + agentChatService: ctx.agentChatService, + sessionService: ctx.sessionService, + issueInventoryService: ctx.issueInventoryService, + }, + { + prId: arg.prId, + scope: "comments", + modelId: arg.modelId, + reasoning: arg.reasoning ?? null, + permissionMode: arg.permissionMode, + additionalInstructions, + }, + ); + }, + ); + // Issue Inventory (PR convergence loop) ipcMain.handle(IPC.prsIssueInventorySync, async (_e, args: { prId: string }): Promise => { const ctx = getCtx(); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 2908a550d..4c5e028bc 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -1961,6 +1961,80 @@ describe("laneService missionId and laneRole", () => { } }); + it("createChild with baseBranchRef tracks remote-only branch and bases lane on it", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-child-base-override-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-child-base-override", repoRoot }); + + const trackCalls: string[][] = []; + + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "branch" && args[1] === "--track") { + trackCalls.push([...args]); + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + if (args[0] === "worktree" && args[1] === "add") { + // worktree add -b + expect(args[2]).toBe("-b"); + expect(args[5]).toBe("sha-feature-remote"); + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/origin/feature/remote-only") { + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (args[0] === "fetch" && args[1] === "--prune" && args[2] === "--all") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/remotes/origin/feature/remote-only") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/feature/remote-only") { + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "feature/remote-only") { + return { exitCode: 0, stdout: "sha-feature-remote\n", stderr: "" }; + } + if (args[0] === "push" && args[1] === "-u") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "status" && args[1] === "--porcelain=v1") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { + return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "--symbolic-full-name" && args[3] === "@{upstream}") { + return { exitCode: 1, stdout: "", stderr: "fatal: no upstream configured" }; + } + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { + return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-child-base-override", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const lane = await service.createChild({ + parentLaneId: "lane-parent", + name: "Override base child", + baseBranchRef: "origin/feature/remote-only", + }); + + expect(trackCalls).toEqual([["branch", "--track", "feature/remote-only", "origin/feature/remote-only"]]); + expect(lane.baseRef).toBe("feature/remote-only"); + expect(lane.parentLaneId).toBe("lane-parent"); + }); + it("setMissionOwnership updates missionId and laneRole on an existing lane", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-set-ownership-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 88d9ee705..3f20ca16d 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -1292,6 +1292,48 @@ export function createLaneService({ if (!parent) throw new Error(`Parent lane not found: ${args.parentLaneId}`); if (parent.status === "archived") throw new Error("Parent lane is archived"); + const trimmedBaseBranchRef = args.baseBranchRef?.trim() ?? ""; + const hasOverride = trimmedBaseBranchRef.length > 0 && trimmedBaseBranchRef !== parent.branch_ref; + + if (hasOverride) { + let localBranchName = trimmedBaseBranchRef; + const localExists = await runGit( + ["show-ref", "--verify", "--quiet", `refs/heads/${trimmedBaseBranchRef}`], + { cwd: projectRoot, timeoutMs: 8_000 }, + ).then((r) => r.exitCode === 0); + + if (!localExists) { + const resolved = await resolveImportBranchTarget({ projectRoot, rawRef: trimmedBaseBranchRef }); + localBranchName = resolved.localBranchName; + const resolvedLocalExists = await runGit( + ["show-ref", "--verify", "--quiet", `refs/heads/${resolved.localBranchName}`], + { cwd: projectRoot, timeoutMs: 8_000 }, + ).then((r) => r.exitCode === 0); + if (!resolvedLocalExists) { + await runGitOrThrow( + ["branch", "--track", resolved.localBranchName, resolved.remoteRef], + { cwd: projectRoot, timeoutMs: 15_000 }, + ); + } + } + + const headRes = await runGit(["rev-parse", localBranchName], { cwd: projectRoot, timeoutMs: 10_000 }); + const startPoint = headRes.exitCode === 0 && headRes.stdout.trim().length + ? headRes.stdout.trim() + : localBranchName; + + return await createWorktreeLane({ + name: args.name, + description: args.description, + baseRef: localBranchName, + startPoint, + parentLaneId: parent.id, + folder: args.folder, + missionId: args.missionId ?? null, + laneRole: args.laneRole ?? null, + }); + } + if (parent.lane_type === "primary") { const requestedBaseRef = defaultBaseRef; const headRes = await runGit(["rev-parse", requestedBaseRef], { cwd: projectRoot, timeoutMs: 10_000 }); diff --git a/apps/desktop/src/main/services/memory/compactionFlushPrompt.ts b/apps/desktop/src/main/services/memory/compactionFlushPrompt.ts new file mode 100644 index 000000000..3819a557c --- /dev/null +++ b/apps/desktop/src/main/services/memory/compactionFlushPrompt.ts @@ -0,0 +1,8 @@ +export const DEFAULT_FLUSH_PROMPT = [ + "Before context compaction runs, review the conversation for durable discoveries worth preserving.", + "Quality bar: would a developer joining this project find this useful on their first day? If not, skip it.", + "Each memory should be a single actionable insight, not a paragraph of context. Lead with the rule or fact, then brief context for WHY.", + "SAVE: non-obvious conventions, decisions with reasoning, pitfalls others would repeat, patterns that contradict expectations.", + "DO NOT SAVE: file paths, session progress, task status, code that is already committed, raw error messages without lessons, anything discoverable via search or git log.", + 'If nothing qualifies — and often nothing will — respond with "NO_DISCOVERIES". Fewer high-quality memories are better than many low-quality ones.' +].join(" "); diff --git a/apps/desktop/src/main/services/memory/compactionFlushService.test.ts b/apps/desktop/src/main/services/memory/compactionFlushService.test.ts deleted file mode 100644 index f7bb73a20..000000000 --- a/apps/desktop/src/main/services/memory/compactionFlushService.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createCompactionFlushService } from "./compactionFlushService"; - -describe("compactionFlushService", () => { - it("uses sensible default config", () => { - const service = createCompactionFlushService(); - - expect(service.getConfig()).toMatchObject({ - enabled: true, - reserveTokensFloor: 40_000, - maxFlushTurnsPerSession: 3, - }); - expect(service.getConfig().flushPrompt).toContain("compaction"); - }); - - it("injects a hidden system message and runs a flush turn when the threshold is exceeded", async () => { - const service = createCompactionFlushService(); - const appendHiddenMessage = vi.fn(); - const flushTurn = vi.fn(async () => ({ status: "flushed" as const })); - - const result = await service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-1", - conversationTokenCount: 70_001, - maxTokens: 100_000, - appendHiddenMessage, - flushTurn, - }); - - expect(result).toMatchObject({ - injected: true, - flushCount: 1, - reason: "flushed", - }); - expect(appendHiddenMessage).toHaveBeenCalledWith( - expect.objectContaining({ - role: "system", - hidden: true, - content: expect.stringContaining("compaction"), - }), - ); - expect(flushTurn).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: "session-1", - boundaryId: "boundary-1", - prompt: expect.stringContaining("compaction"), - }), - ); - }); - - it("does nothing when disabled", async () => { - const service = createCompactionFlushService({ enabled: false }); - const appendHiddenMessage = vi.fn(); - const flushTurn = vi.fn(); - - const result = await service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-1", - conversationTokenCount: 99_000, - maxTokens: 100_000, - appendHiddenMessage, - flushTurn, - }); - - expect(result).toMatchObject({ - injected: false, - flushCount: 0, - reason: "disabled", - }); - expect(appendHiddenMessage).not.toHaveBeenCalled(); - expect(flushTurn).not.toHaveBeenCalled(); - }); - - it("does nothing below the reserve threshold", async () => { - const service = createCompactionFlushService({ reserveTokensFloor: 20_000 }); - const appendHiddenMessage = vi.fn(); - const flushTurn = vi.fn(); - - const result = await service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-1", - conversationTokenCount: 79_999, - maxTokens: 100_000, - appendHiddenMessage, - flushTurn, - }); - - expect(result).toMatchObject({ - injected: false, - flushCount: 0, - reason: "below-threshold", - }); - expect(appendHiddenMessage).not.toHaveBeenCalled(); - expect(flushTurn).not.toHaveBeenCalled(); - }); - - it("prevents a second flush for the same compaction boundary", async () => { - const service = createCompactionFlushService(); - const flushTurn = vi.fn(async () => ({ status: "flushed" as const })); - - const first = await service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-1", - conversationTokenCount: 75_000, - maxTokens: 100_000, - flushTurn, - }); - const second = await service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-1", - conversationTokenCount: 80_000, - maxTokens: 100_000, - flushTurn, - }); - - expect(first.reason).toBe("flushed"); - expect(second).toMatchObject({ - injected: false, - flushCount: 1, - reason: "already-flushed-boundary", - }); - expect(flushTurn).toHaveBeenCalledTimes(1); - }); - - it("enforces the max flush turns per session across boundaries", async () => { - const service = createCompactionFlushService({ maxFlushTurnsPerSession: 2 }); - const flushTurn = vi.fn(async () => ({ status: "flushed" as const })); - - await service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-1", - conversationTokenCount: 75_000, - maxTokens: 100_000, - flushTurn, - }); - await service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-2", - conversationTokenCount: 80_000, - maxTokens: 100_000, - flushTurn, - }); - const third = await service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-3", - conversationTokenCount: 85_000, - maxTokens: 100_000, - flushTurn, - }); - - expect(third).toMatchObject({ - injected: false, - flushCount: 2, - reason: "max-flush-turns-reached", - }); - expect(flushTurn).toHaveBeenCalledTimes(2); - }); - - it("uses a custom flush prompt when provided", async () => { - const service = createCompactionFlushService({ - flushPrompt: "Persist durable findings with memoryAdd before compaction.", - }); - const appendHiddenMessage = vi.fn(); - - await service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-1", - conversationTokenCount: 90_000, - maxTokens: 100_000, - appendHiddenMessage, - flushTurn: async () => ({ status: "flushed" as const }), - }); - - expect(appendHiddenMessage).toHaveBeenCalledWith( - expect.objectContaining({ - content: "Persist durable findings with memoryAdd before compaction.", - }), - ); - }); - - it("treats flush failures as best-effort and still allows compaction to proceed", async () => { - const service = createCompactionFlushService(); - - await expect( - service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-1", - conversationTokenCount: 90_000, - maxTokens: 100_000, - flushTurn: async () => { - throw new Error("tool failure"); - }, - }), - ).resolves.toMatchObject({ - injected: true, - flushCount: 1, - reason: "flush-failed", - proceedWithCompaction: true, - }); - }); - - it("treats over-budget flush attempts as best-effort and still allows compaction to proceed", async () => { - const service = createCompactionFlushService(); - - await expect( - service.beforeCompaction({ - sessionId: "session-1", - boundaryId: "boundary-1", - conversationTokenCount: 90_000, - maxTokens: 100_000, - flushTurn: async () => ({ status: "budget_exceeded" as const }), - }), - ).resolves.toMatchObject({ - injected: true, - flushCount: 1, - reason: "flush-budget-exceeded", - proceedWithCompaction: true, - }); - }); -}); diff --git a/apps/desktop/src/main/services/memory/compactionFlushService.ts b/apps/desktop/src/main/services/memory/compactionFlushService.ts deleted file mode 100644 index 7377e3db6..000000000 --- a/apps/desktop/src/main/services/memory/compactionFlushService.ts +++ /dev/null @@ -1,227 +0,0 @@ -import type { Logger } from "../logging/logger"; - -export interface CompactionFlushConfig { - enabled?: boolean; - reserveTokensFloor?: number; - maxFlushTurnsPerSession?: number; - flushPrompt?: string; -} - -export type CompactionFlushHiddenMessage = { - role: "system"; - content: string; - hidden: true; -}; - -export type CompactionFlushTurnStatus = "flushed" | "budget_exceeded"; - -export type CompactionFlushContext = { - sessionId: string; - boundaryId: string; - conversationTokenCount: number; - maxTokens: number; - appendHiddenMessage?: (message: CompactionFlushHiddenMessage) => void | Promise; - flushTurn?: (args: { - sessionId: string; - boundaryId: string; - prompt: string; - }) => Promise<{ status: CompactionFlushTurnStatus } | void>; -}; - -export type CompactionFlushResult = { - injected: boolean; - flushCount: number; - reason: - | "disabled" - | "below-threshold" - | "flush-handler-unavailable" - | "already-flushed-boundary" - | "max-flush-turns-reached" - | "flushed" - | "flush-failed" - | "flush-budget-exceeded"; - proceedWithCompaction: true; -}; - -type NormalizedCompactionFlushConfig = Required; - -type SessionFlushState = { - flushCount: number; - flushedBoundaries: Set; -}; - -const DEFAULT_FLUSH_PROMPT = [ - "Before context compaction runs, review the conversation for durable discoveries worth preserving.", - "Quality bar: would a developer joining this project find this useful on their first day? If not, skip it.", - "Each memory should be a single actionable insight, not a paragraph of context. Lead with the rule or fact, then brief context for WHY.", - "SAVE: non-obvious conventions, decisions with reasoning, pitfalls others would repeat, patterns that contradict expectations.", - "DO NOT SAVE: file paths, session progress, task status, code that is already committed, raw error messages without lessons, anything discoverable via search or git log.", - 'If nothing qualifies — and often nothing will — respond with "NO_DISCOVERIES". Fewer high-quality memories are better than many low-quality ones.' -].join(" "); - -function normalizePositiveInteger(value: number | undefined, fallback: number): number { - if (!Number.isFinite(value) || Number(value) <= 0) return fallback; - return Math.floor(Number(value)); -} - -function normalizeConfig(config: CompactionFlushConfig | undefined): NormalizedCompactionFlushConfig { - const flushPrompt = typeof config?.flushPrompt === "string" && config.flushPrompt.trim().length > 0 - ? config.flushPrompt.trim() - : DEFAULT_FLUSH_PROMPT; - - return { - enabled: config?.enabled !== false, - reserveTokensFloor: normalizePositiveInteger(config?.reserveTokensFloor, 40_000), - maxFlushTurnsPerSession: normalizePositiveInteger(config?.maxFlushTurnsPerSession, 3), - flushPrompt, - }; -} - -function normalizeSessionId(value: string): string { - const normalized = String(value ?? "").trim(); - return normalized.length > 0 ? normalized : "default-session"; -} - -function normalizeBoundaryId(value: string, conversationTokenCount: number): string { - const normalized = String(value ?? "").trim(); - return normalized.length > 0 ? normalized : `boundary-${Math.max(0, Math.floor(conversationTokenCount))}`; -} - -export type CompactionFlushService = ReturnType; - -export function createCompactionFlushService( - config?: CompactionFlushConfig, - deps?: { logger?: Pick }, -) { - const logger = deps?.logger; - const normalizedConfig = normalizeConfig(config); - const sessionState = new Map(); - - function getOrCreateSessionState(sessionId: string): SessionFlushState { - const existing = sessionState.get(sessionId); - if (existing) return existing; - const created: SessionFlushState = { - flushCount: 0, - flushedBoundaries: new Set(), - }; - sessionState.set(sessionId, created); - return created; - } - - async function beforeCompaction(args: CompactionFlushContext): Promise { - if (!normalizedConfig.enabled) { - return { - injected: false, - flushCount: 0, - reason: "disabled", - proceedWithCompaction: true, - }; - } - - const threshold = Math.max(0, Math.floor(args.maxTokens) - normalizedConfig.reserveTokensFloor); - if (!Number.isFinite(args.conversationTokenCount) || args.conversationTokenCount < threshold) { - return { - injected: false, - flushCount: 0, - reason: "below-threshold", - proceedWithCompaction: true, - }; - } - - if (!args.flushTurn) { - return { - injected: false, - flushCount: 0, - reason: "flush-handler-unavailable", - proceedWithCompaction: true, - }; - } - - const sessionId = normalizeSessionId(args.sessionId); - const boundaryId = normalizeBoundaryId(args.boundaryId, args.conversationTokenCount); - const state = getOrCreateSessionState(sessionId); - - if (state.flushedBoundaries.has(boundaryId)) { - return { - injected: false, - flushCount: state.flushCount, - reason: "already-flushed-boundary", - proceedWithCompaction: true, - }; - } - - if (state.flushCount >= normalizedConfig.maxFlushTurnsPerSession) { - return { - injected: false, - flushCount: state.flushCount, - reason: "max-flush-turns-reached", - proceedWithCompaction: true, - }; - } - - state.flushedBoundaries.add(boundaryId); - state.flushCount += 1; - - const hiddenMessage: CompactionFlushHiddenMessage = { - role: "system", - content: normalizedConfig.flushPrompt, - hidden: true, - }; - - try { - await args.appendHiddenMessage?.(hiddenMessage); - } catch (error) { - logger?.warn("memory.compaction_flush.hidden_message_failed", { - sessionId, - boundaryId, - error: error instanceof Error ? error.message : String(error), - }); - } - - try { - const flushResult = await args.flushTurn({ - sessionId, - boundaryId, - prompt: normalizedConfig.flushPrompt, - }); - - return { - injected: true, - flushCount: state.flushCount, - reason: flushResult?.status === "budget_exceeded" ? "flush-budget-exceeded" : "flushed", - proceedWithCompaction: true, - }; - } catch (error) { - logger?.warn("memory.compaction_flush.flush_failed", { - sessionId, - boundaryId, - error: error instanceof Error ? error.message : String(error), - }); - return { - injected: true, - flushCount: state.flushCount, - reason: "flush-failed", - proceedWithCompaction: true, - }; - } - } - - function getConfig(): NormalizedCompactionFlushConfig { - return { ...normalizedConfig }; - } - - function getSessionFlushCount(sessionId: string): number { - return sessionState.get(normalizeSessionId(sessionId))?.flushCount ?? 0; - } - - function clearSession(sessionId: string): void { - sessionState.delete(normalizeSessionId(sessionId)); - } - - return { - beforeCompaction, - getConfig, - getSessionFlushCount, - clearSession, - }; -} diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts index 7926b2376..8f90a4701 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts @@ -1,8 +1,15 @@ +import fs from "node:fs"; import { createHash } from "node:crypto"; +import { createServer, type Server } from "node:net"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stableStringify } from "../shared/utils"; const originalFetch = global.fetch; +let dynamicSocketServer: Server | null = null; +let dynamicSocketDir: string | null = null; +let dynamicSocketPath = "/tmp/ade.sock"; const mockState = vi.hoisted(() => { let nextSessionId = 1; @@ -123,7 +130,7 @@ function createLaunch(overrides: Partial> = {}) { }, entryPath: "dist/main/adeMcpProxy.cjs", runtimeRoot: null, - socketPath: "/tmp/ade.sock", + socketPath: dynamicSocketPath, packaged: false, resourcesPath: null, }; @@ -159,15 +166,48 @@ function expectedDynamicServerName(args: { } describe("openCodeRuntime dynamic ADE MCP registration", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); mockState.resetSessionIds(); __resetOpenCodeRuntimeDiagnosticsForTests(); global.fetch = vi.fn(); + dynamicSocketDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-opencode-sock-")); + dynamicSocketPath = path.join(dynamicSocketDir, "mcp.sock"); + dynamicSocketServer = createServer((socket) => { + socket.end(); + }); + await new Promise((resolve, reject) => { + dynamicSocketServer!.once("error", reject); + dynamicSocketServer!.listen(dynamicSocketPath, resolve); + }); }); - afterEach(() => { + afterEach(async () => { global.fetch = originalFetch; + await new Promise((resolve) => { + if (!dynamicSocketServer) { + resolve(); + return; + } + dynamicSocketServer.close(() => resolve()); + }); + dynamicSocketServer = null; + if (dynamicSocketPath) { + try { + fs.unlinkSync(dynamicSocketPath); + } catch { + // ignore + } + } + if (dynamicSocketDir) { + try { + fs.rmdirSync(dynamicSocketDir); + } catch { + // ignore + } + } + dynamicSocketDir = null; + dynamicSocketPath = "/tmp/ade.sock"; }); it("registers a per-session ADE MCP server on the shared OpenCode runtime and scopes tools to it", async () => { @@ -522,42 +562,101 @@ describe("openCodeRuntime dynamic ADE MCP registration", () => { expect.objectContaining({ ownerKind: "chat", ownerId: "chat-fallback", + fallbackStrategy: "dedicated_static", }), ); }); - it("retries dynamic ADE MCP registration before falling back", async () => { - vi.useFakeTimers(); - try { - vi.mocked(global.fetch) - .mockRejectedValueOnce(new Error("server warming up")) - .mockRejectedValueOnce(new Error("server warming up")) - .mockResolvedValueOnce(new Response("{}", { status: 200 })) - .mockResolvedValueOnce(new Response("{}", { status: 200 })) - .mockResolvedValueOnce(new Response("true", { status: 200 })); - - const promise = startOpenCodeSession({ - directory: "/repo", - title: "Retry chat", - leaseKind: "shared", - projectConfig: { ai: {} }, - dynamicMcpLaunch: createLaunch({ - ADE_CHAT_SESSION_ID: "chat-retry", - }), + it("skips dedicated fallback and degrades to a shared session without ADE MCP tools when the socket is unavailable", async () => { + vi.mocked(global.fetch).mockRejectedValue( + new Error("[ade-mcp-proxy] Failed to connect: connect ENOENT /tmp/ade.sock"), + ); + const logger = { warn: vi.fn() } as any; + + const handle = await startOpenCodeSession({ + directory: "/repo", + title: "No MCP chat", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: createLaunch({ + ADE_CHAT_SESSION_ID: "chat-no-mcp", + }), + ownerKind: "chat", + ownerId: "chat-no-mcp", + ownerKey: "chat:chat-no-mcp", + logger, + }); + + expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(2); + expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); + expect(handle.toolSelection).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + "opencode.dynamic_mcp_attach_failed", + expect.objectContaining({ ownerKind: "chat", - ownerId: "chat-retry", - ownerKey: "chat:chat-retry", - }); - - await vi.advanceTimersByTimeAsync(2 * 150); - const handle = await promise; - - expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(1); - expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); - expect(handle.toolSelection).toBeTruthy(); - expect(vi.mocked(global.fetch)).toHaveBeenCalledTimes(6); - } finally { - vi.useRealTimers(); - } + ownerId: "chat-no-mcp", + fallbackStrategy: "shared_without_mcp", + }), + ); + }); + + it("fails fast for coordinator sessions when ADE MCP socket startup is unrecoverable", async () => { + vi.mocked(global.fetch).mockRejectedValue( + new Error("local mcp startup failed: [ade-mcp-proxy] Failed to connect: connect ENOENT /tmp/ade.sock"), + ); + const logger = { warn: vi.fn() } as any; + + await expect(startOpenCodeSession({ + directory: "/repo", + title: "Coordinator", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: createLaunch({ + ADE_RUN_ID: "run-1", + }), + ownerKind: "coordinator", + ownerId: "run-1", + ownerKey: "coordinator:run-1", + logger, + })).rejects.toThrow(/local mcp startup failed/i); + + expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(1); + expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + "opencode.dynamic_mcp_attach_failed", + expect.objectContaining({ + ownerKind: "coordinator", + ownerId: "run-1", + fallbackStrategy: "abort", + }), + ); + }); + + it("retries dynamic ADE MCP registration before falling back", async () => { + vi.mocked(global.fetch) + .mockRejectedValueOnce(new Error("server warming up")) + .mockRejectedValueOnce(new Error("server warming up")) + .mockResolvedValueOnce(new Response("{}", { status: 200 })) + .mockResolvedValueOnce(new Response("{}", { status: 200 })) + .mockResolvedValueOnce(new Response("true", { status: 200 })) + .mockResolvedValueOnce(new Response("{}", { status: 200 })); + + const handle = await startOpenCodeSession({ + directory: "/repo", + title: "Retry chat", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: createLaunch({ + ADE_CHAT_SESSION_ID: "chat-retry", + }), + ownerKind: "chat", + ownerId: "chat-retry", + ownerKey: "chat:chat-retry", + }); + + expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(1); + expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); + expect(handle.toolSelection).toBeTruthy(); + expect(vi.mocked(global.fetch)).toHaveBeenCalledTimes(6); }); }); diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts index 41dffc00b..98f7d0ecb 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts @@ -1,5 +1,6 @@ +import fs from "node:fs"; import { createHash, randomUUID } from "node:crypto"; -import { createServer } from "node:net"; +import { createConnection, createServer } from "node:net"; import { pathToFileURL } from "node:url"; import { createOpencodeClient, @@ -305,6 +306,8 @@ async function resolveScopedMcpToolSelection(args: { const DYNAMIC_ADE_MCP_REGISTRATION_ATTEMPTS = 3; const DYNAMIC_ADE_MCP_REGISTRATION_RETRY_DELAY_MS = 150; +const DYNAMIC_ADE_MCP_SOCKET_READY_TIMEOUT_MS = 1_500; +const DYNAMIC_ADE_MCP_SOCKET_READY_RETRY_DELAY_MS = 75; const dynamicMcpDiagnostics: OpenCodeDynamicMcpDiagnostics = { registrationAttempts: 0, successfulRegistrations: 0, @@ -320,6 +323,58 @@ async function wait(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitForAdeMcpSocketReady(socketPath: string): Promise { + const normalizedPath = socketPath.trim(); + if (!normalizedPath.length) return; + const deadline = Date.now() + DYNAMIC_ADE_MCP_SOCKET_READY_TIMEOUT_MS; + let lastError: unknown = null; + + while (Date.now() < deadline) { + if (fs.existsSync(normalizedPath)) { + try { + await new Promise((resolve, reject) => { + const socket = createConnection(normalizedPath); + const cleanup = (): void => { + socket.off("connect", handleConnect); + socket.off("error", handleError); + }; + const handleConnect = () => { + cleanup(); + socket.end(); + socket.destroy(); + resolve(); + }; + const handleError = (error: Error) => { + cleanup(); + socket.destroy(); + reject(error); + }; + socket.once("connect", handleConnect); + socket.once("error", handleError); + }); + return; + } catch (error) { + lastError = error; + } + } + await wait(DYNAMIC_ADE_MCP_SOCKET_READY_RETRY_DELAY_MS); + } + + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + throw new Error(`ADE MCP socket not ready at ${normalizedPath}${detail}`); +} + +function isUnrecoverableAdeMcpSocketError(error: unknown): boolean { + const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); + return ( + message.includes("enoent") + || message.includes("econnrefused") + || message.includes("local mcp startup failed") + || message.includes("[ade-mcp-proxy] failed to connect") + || (message.includes("mcp.sock") && message.includes("connect")) + ); +} + async function callOpenCodeServer(args: { baseUrl: string; directory: string; @@ -367,6 +422,7 @@ async function ensureDynamicAdeMcpRegistration(args: { let lastError: unknown = null; for (let attempt = 0; attempt < DYNAMIC_ADE_MCP_REGISTRATION_ATTEMPTS; attempt += 1) { try { + await waitForAdeMcpSocketReady(args.launch.socketPath); let status = readDynamicMcpStatus(await callOpenCodeServer>({ baseUrl: args.baseUrl, directory: args.directory, @@ -786,17 +842,32 @@ export async function startOpenCodeSession( mcpLaunch: undefined, }); } catch (error) { - dynamicMcpDiagnostics.fallbackCount += 1; - dynamicMcpDiagnostics.lastFallbackAt = new Date().toISOString(); - dynamicMcpDiagnostics.lastFallbackOwnerKind = args.ownerKind ?? "oneshot"; - dynamicMcpDiagnostics.lastFallbackOwnerId = args.ownerId?.trim() || null; - dynamicMcpDiagnostics.lastFallbackError = error instanceof Error ? error.message : String(error); + const ownerKind = args.ownerKind ?? "oneshot"; + const fallbackStrategy = isUnrecoverableAdeMcpSocketError(error) + ? (ownerKind === "coordinator" ? "abort" : "shared_without_mcp") + : "dedicated_static"; args.logger?.warn("opencode.dynamic_mcp_attach_failed", { - ownerKind: args.ownerKind ?? "oneshot", + ownerKind, ownerId: args.ownerId ?? null, sessionId: args.sessionId ?? null, error: error instanceof Error ? error.message : String(error), + fallbackStrategy, }); + if (fallbackStrategy === "abort") { + throw error; + } + dynamicMcpDiagnostics.fallbackCount += 1; + dynamicMcpDiagnostics.lastFallbackAt = new Date().toISOString(); + dynamicMcpDiagnostics.lastFallbackOwnerKind = ownerKind; + dynamicMcpDiagnostics.lastFallbackOwnerId = args.ownerId?.trim() || null; + dynamicMcpDiagnostics.lastFallbackError = error instanceof Error ? error.message : String(error); + if (fallbackStrategy === "shared_without_mcp") { + return await startOpenCodeSessionInternal({ + ...args, + dynamicMcpLaunch: undefined, + mcpLaunch: undefined, + }); + } return await startOpenCodeSessionInternal({ ...args, dynamicMcpLaunch: undefined, diff --git a/apps/desktop/src/main/services/prs/prPollingService.ts b/apps/desktop/src/main/services/prs/prPollingService.ts index d9f23e401..070a72deb 100644 --- a/apps/desktop/src/main/services/prs/prPollingService.ts +++ b/apps/desktop/src/main/services/prs/prPollingService.ts @@ -1,6 +1,7 @@ import type { Logger } from "../logging/logger"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createPrService } from "./prService"; +import type { AdeDb } from "../state/kvDb"; import type { PrEventPayload, PrNotificationKind, PrSummary } from "../../../shared/types"; import { nowIso } from "../shared/utils"; @@ -57,7 +58,8 @@ export function createPrPollingService({ prService, projectConfigService, onEvent, - onPullRequestsChanged + onPullRequestsChanged, + db, }: { logger: Logger; prService: ReturnType; @@ -74,6 +76,8 @@ export function createPrPollingService({ }>; polledAt: string; }) => void | Promise; + /** Optional database handle used to persist `last_polled_at` per PR for delta polling. */ + db?: AdeDb; }) { const DEFAULT_INTERVAL_MS = 60_000; const MIN_INTERVAL_MS = 5_000; @@ -87,6 +91,27 @@ export function createPrPollingService({ return DEFAULT_INTERVAL_MS; }; + const getLastPolledAt = (prId: string): string | null => { + if (!db) return null; + const row = db.get<{ last_polled_at: string | null }>( + "select last_polled_at from pull_requests where id = ? limit 1", + [prId], + ); + return row?.last_polled_at ?? null; + }; + + const setLastPolledAt = (prId: string, iso: string): void => { + if (!db) return; + try { + db.run("update pull_requests set last_polled_at = ? where id = ?", [iso, prId]); + } catch (err) { + logger.warn("prs.last_polled_at_update_failed", { + prId, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + let stopped = false; let started = false; let timer: NodeJS.Timeout | null = null; @@ -284,6 +309,12 @@ export function createPrPollingService({ } } + // Update last_polled_at cursor per PR. Enables delta polling on the next tick: + // callers can pass `since=last_polled_at` when fetching review threads / comments. + for (const pr of prs) { + setLastPolledAt(pr.id, polledAt); + } + consecutiveFailures = 0; } catch (error) { consecutiveFailures += 1; @@ -321,6 +352,7 @@ export function createPrPollingService({ return { start, poke, + getLastPolledAt, dispose() { stopped = true; if (timer) clearTimeout(timer); diff --git a/apps/desktop/src/main/services/prs/prService.timelineRails.test.ts b/apps/desktop/src/main/services/prs/prService.timelineRails.test.ts new file mode 100644 index 000000000..b016281c5 --- /dev/null +++ b/apps/desktop/src/main/services/prs/prService.timelineRails.test.ts @@ -0,0 +1,467 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { LaneSummary } from "../../../shared/types"; +import { openKvDb } from "../state/kvDb"; +import { createPrService } from "./prService"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as const; +} + +function makeLane(id: string, name: string, branchRef: string, overrides: Partial = {}): LaneSummary { + return { + id, + name, + description: null, + laneType: "worktree", + baseRef: "refs/heads/main", + branchRef, + worktreePath: `/tmp/${id}`, + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-04-14T00:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +async function seedProject(db: any, projectId: string, repoRoot: string) { + const now = "2026-04-14T00:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "ADE", "main", now, now], + ); +} + +async function seedLane(db: any, projectId: string, lane: LaneSummary) { + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + lane.id, + projectId, + lane.name, + lane.description, + lane.laneType, + lane.baseRef, + lane.branchRef, + lane.worktreePath, + lane.attachedRootPath, + lane.isEditProtected ? 1 : 0, + lane.parentLaneId, + lane.color, + lane.icon, + JSON.stringify(lane.tags), + "active", + lane.createdAt, + lane.archivedAt, + ], + ); +} + +async function seedPr(db: any, args: { prId: string; projectId: string; laneId: string; prNumber: number }) { + const now = "2026-04-14T00:00:00.000Z"; + db.run( + ` + insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + args.prId, + args.projectId, + args.laneId, + "arul28", + "ADE", + args.prNumber, + `https://github.com/arul28/ADE/pull/${args.prNumber}`, + null, + "Timeline + Rails", + "open", + "main", + "feature/timeline", + "passing", + "approved", + 10, + 0, + now, + now, + now, + ], + ); +} + +function mockReviewThreadsResponse() { + return { + data: { + data: { + repository: { + pullRequest: { + reviewThreads: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: "thread-abc", + isResolved: false, + isOutdated: false, + path: "src/app.ts", + line: 10, + originalLine: 10, + startLine: null, + originalStartLine: null, + diffSide: "RIGHT", + comments: { + nodes: [ + { + id: "comment-1", + body: "Please fix.", + url: "https://github.com/x/y/pull/1#r1", + createdAt: "2026-04-14T01:00:00.000Z", + updatedAt: "2026-04-14T01:00:00.000Z", + author: { login: "rev", avatarUrl: null }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + }, + }; +} + +async function buildService(root: string) { + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + const projectId = "proj-timeline"; + const lane = makeLane("lane-1", "feature/timeline", "refs/heads/feature/timeline", { worktreePath: root }); + await seedProject(db, projectId, root); + await seedLane(db, projectId, lane); + await seedPr(db, { prId: "pr-1", projectId, laneId: lane.id, prNumber: 42 }); + return { db, projectId, lane }; +} + +describe("prService.postReviewComment", () => { + it("issues an addPullRequestReviewThreadReply GraphQL mutation and maps the response", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-post-")); + const { db, projectId, lane } = await buildService(root); + try { + const apiRequest = vi.fn(async ({ path: requestPath, body }: any) => { + if (requestPath !== "/graphql") return { data: {} }; + const query: string = body?.query ?? ""; + if (query.includes("reviewThreads(first:")) { + return mockReviewThreadsResponse(); + } + expect(query).toContain("addPullRequestReviewThreadReply"); + expect(body.variables).toMatchObject({ threadId: "thread-abc", body: "Thanks!" }); + return { + data: { + data: { + addPullRequestReviewThreadReply: { + comment: { + id: "comment-2", + body: "Thanks!", + url: "https://github.com/x/y/pull/1#r2", + createdAt: "2026-04-14T02:00:00.000Z", + updatedAt: "2026-04-14T02:00:00.000Z", + author: { login: "me", avatarUrl: "avatar.png" }, + }, + }, + }, + }, + }; + }); + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { list: async () => [lane] } as any, + operationService: {} as any, + githubService: { apiRequest } as any, + aiIntegrationService: undefined, + projectConfigService: {} as any, + conflictService: undefined, + openExternal: async () => {}, + }); + await expect( + service.postReviewComment({ prId: "pr-1", threadId: "thread-abc", body: "Thanks!" }), + ).resolves.toEqual({ + id: "comment-2", + author: "me", + authorAvatarUrl: "avatar.png", + body: "Thanks!", + url: "https://github.com/x/y/pull/1#r2", + createdAt: "2026-04-14T02:00:00.000Z", + updatedAt: "2026-04-14T02:00:00.000Z", + }); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects when the thread does not belong to the PR", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-post-wrong-")); + const { db, projectId, lane } = await buildService(root); + try { + const apiRequest = vi.fn(async ({ path: p }: any) => { + if (p === "/graphql") return mockReviewThreadsResponse(); + return { data: {} }; + }); + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { list: async () => [lane] } as any, + operationService: {} as any, + githubService: { apiRequest } as any, + aiIntegrationService: undefined, + projectConfigService: {} as any, + conflictService: undefined, + openExternal: async () => {}, + }); + await expect( + service.postReviewComment({ prId: "pr-1", threadId: "not-mine", body: "..." }), + ).rejects.toThrow(/does not belong/); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("prService.setReviewThreadResolved", () => { + it("uses resolveReviewThread mutation when resolved=true", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-resolve-")); + const { db, projectId, lane } = await buildService(root); + try { + const apiRequest = vi.fn(async ({ path: p, body }: any) => { + if (p !== "/graphql") return { data: {} }; + const q: string = body?.query ?? ""; + if (q.includes("reviewThreads(first:")) return mockReviewThreadsResponse(); + expect(q).toContain("resolveReviewThread("); + expect(q).not.toContain("unresolveReviewThread"); + return { + data: { data: { resolveReviewThread: { thread: { id: "thread-abc", isResolved: true } } } }, + }; + }); + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { list: async () => [lane] } as any, + operationService: {} as any, + githubService: { apiRequest } as any, + aiIntegrationService: undefined, + projectConfigService: {} as any, + conflictService: undefined, + openExternal: async () => {}, + }); + await expect( + service.setReviewThreadResolved({ prId: "pr-1", threadId: "thread-abc", resolved: true }), + ).resolves.toEqual({ threadId: "thread-abc", isResolved: true }); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("uses unresolveReviewThread mutation when resolved=false", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-unresolve-")); + const { db, projectId, lane } = await buildService(root); + try { + const apiRequest = vi.fn(async ({ path: p, body }: any) => { + if (p !== "/graphql") return { data: {} }; + const q: string = body?.query ?? ""; + if (q.includes("reviewThreads(first:")) return mockReviewThreadsResponse(); + expect(q).toContain("unresolveReviewThread("); + return { + data: { data: { unresolveReviewThread: { thread: { id: "thread-abc", isResolved: false } } } }, + }; + }); + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { list: async () => [lane] } as any, + operationService: {} as any, + githubService: { apiRequest } as any, + aiIntegrationService: undefined, + projectConfigService: {} as any, + conflictService: undefined, + openExternal: async () => {}, + }); + await expect( + service.setReviewThreadResolved({ prId: "pr-1", threadId: "thread-abc", resolved: false }), + ).resolves.toEqual({ threadId: "thread-abc", isResolved: false }); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("prService.reactToComment", () => { + it("issues addReaction with the correct enum value", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-react-")); + const { db, projectId, lane } = await buildService(root); + try { + const apiRequest = vi.fn(async ({ path: p, body }: any) => { + if (p !== "/graphql") return { data: {} }; + const q: string = body?.query ?? ""; + expect(q).toContain("addReaction("); + expect(body.variables).toEqual({ subjectId: "comment-1", content: "ROCKET" }); + return { data: { data: { addReaction: { reaction: { id: "r1", content: "ROCKET" } } } } }; + }); + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { list: async () => [lane] } as any, + operationService: {} as any, + githubService: { apiRequest } as any, + aiIntegrationService: undefined, + projectConfigService: {} as any, + conflictService: undefined, + openExternal: async () => {}, + }); + await expect( + service.reactToComment({ prId: "pr-1", commentId: "comment-1", content: "rocket" }), + ).resolves.toBeUndefined(); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("prService.getDeployments", () => { + it("maps GitHub deployments + latest status into PrDeployment shape", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-deploy-")); + const { db, projectId, lane } = await buildService(root); + try { + const apiRequest = vi.fn(async ({ path: p, query }: any) => { + if (p === "/repos/arul28/ADE/pulls/42") { + return { data: { head: { sha: "deadbeef" }, base: { sha: "baseabc" }, state: "open" } }; + } + if (p === "/repos/arul28/ADE/deployments") { + expect(query).toMatchObject({ sha: "deadbeef" }); + return { + data: [ + { + id: 1001, + environment: "staging", + description: "deploy-desc", + payload: { web_url: "https://payload.example" }, + sha: "deadbeef", + ref: "feature/timeline", + creator: { login: "arul" }, + created_at: "2026-04-14T01:00:00.000Z", + updated_at: "2026-04-14T01:00:00.000Z", + }, + ], + }; + } + if (p === "/repos/arul28/ADE/deployments/1001/statuses") { + return { + data: [ + { + state: "success", + environment_url: "https://preview.example", + log_url: "https://logs.example", + target_url: "https://target.example", + updated_at: "2026-04-14T02:00:00.000Z", + }, + ], + }; + } + return { data: [] }; + }); + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { list: async () => [lane] } as any, + operationService: {} as any, + githubService: { apiRequest } as any, + aiIntegrationService: undefined, + projectConfigService: {} as any, + conflictService: undefined, + openExternal: async () => {}, + }); + const deployments = await service.getDeployments("pr-1"); + expect(deployments).toHaveLength(1); + expect(deployments[0]).toMatchObject({ + environment: "staging", + state: "success", + environmentUrl: "https://preview.example", + logUrl: "https://logs.example", + sha: "deadbeef", + ref: "feature/timeline", + creator: "arul", + }); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("returns an empty array when the PR has no head SHA", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-deploy-empty-")); + const { db, projectId, lane } = await buildService(root); + try { + const apiRequest = vi.fn(async ({ path: p }: any) => { + if (p === "/repos/arul28/ADE/pulls/42") { + return { data: { head: {}, base: {} } }; + } + return { data: [] }; + }); + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { list: async () => [lane] } as any, + operationService: {} as any, + githubService: { apiRequest } as any, + aiIntegrationService: undefined, + projectConfigService: {} as any, + conflictService: undefined, + openExternal: async () => {}, + }); + await expect(service.getDeployments("pr-1")).resolves.toEqual([]); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 0e8d6199d..a15d1c346 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -85,6 +85,13 @@ import type { PrReviewThreadComment, ReplyToPrReviewThreadArgs, ResolvePrReviewThreadArgs, + PrDeployment, + PrDeploymentState, + PostPrReviewCommentArgs, + SetPrReviewThreadResolvedArgs, + SetPrReviewThreadResolvedResult, + ReactToPrCommentArgs, + PrReactionContent, } from "../../../shared/types"; import type { AdeDb } from "../state/kvDb"; import type { Logger } from "../logging/logger"; @@ -554,6 +561,34 @@ function toFileStatus(raw: unknown): PrFile["status"] { return "modified"; } +const DEPLOYMENT_STATES = new Set([ + "success", + "failure", + "error", + "pending", + "in_progress", + "queued", + "inactive", +]); + +function toDeploymentState(raw: unknown): PrDeploymentState { + const s = asString(raw).toLowerCase() as PrDeploymentState; + return DEPLOYMENT_STATES.has(s) ? s : "unknown"; +} + +function reactionToGraphqlEnum(content: PrReactionContent): string { + switch (content) { + case "+1": return "THUMBS_UP"; + case "-1": return "THUMBS_DOWN"; + case "laugh": return "LAUGH"; + case "confused": return "CONFUSED"; + case "heart": return "HEART"; + case "hooray": return "HOORAY"; + case "rocket": return "ROCKET"; + case "eyes": return "EYES"; + } +} + function toUser(raw: any): PrUser { return { login: asString(raw?.login) || "", @@ -1570,6 +1605,19 @@ export function createPrService({ } upsertRow(updated); + // Keep `head_sha` in sync so downstream features (AI summary cache, deployments) + // can reliably reference the latest commit on the PR head branch. + if (headSha) { + try { + db.run("update pull_requests set head_sha = ? where id = ? and project_id = ?", [headSha, row.id, projectId]); + } catch (err) { + logger.warn("prs.head_sha_persist_failed", { + prId: row.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return updated; }; @@ -4893,6 +4941,178 @@ export function createPrService({ ); }, + async postReviewComment(args: PostPrReviewCommentArgs): Promise { + const row = requireRow(args.prId); + const repo = repoFromRow(row); + const threads = await fetchReviewThreads(repo, Number(row.github_pr_number)); + if (!threads.some((t) => t.id === args.threadId)) { + throw new Error(`Thread ${args.threadId} does not belong to PR ${args.prId}`); + } + const data = await graphqlRequest<{ + addPullRequestReviewThreadReply?: { + comment?: { + id?: unknown; + body?: unknown; + url?: unknown; + createdAt?: unknown; + updatedAt?: unknown; + author?: { login?: unknown; avatarUrl?: unknown } | null; + } | null; + } | null; + }>( + ` + mutation AdePostReviewComment($threadId: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { + comment { + id + body + url + createdAt + updatedAt + author { login avatarUrl } + } + } + } + `, + { threadId: args.threadId, body: args.body }, + ); + const comment = data.addPullRequestReviewThreadReply?.comment; + if (!comment) { + throw new Error("GitHub did not return the review-thread reply."); + } + return { + id: asString(comment.id) || String(randomUUID()), + author: asString(comment.author?.login) || "unknown", + authorAvatarUrl: asString(comment.author?.avatarUrl) || null, + body: asString(comment.body) || null, + url: asString(comment.url) || null, + createdAt: asString(comment.createdAt) || null, + updatedAt: asString(comment.updatedAt) || null, + }; + }, + + async setReviewThreadResolved(args: SetPrReviewThreadResolvedArgs): Promise { + const row = requireRow(args.prId); + const repo = repoFromRow(row); + const threads = await fetchReviewThreads(repo, Number(row.github_pr_number)); + if (!threads.some((t) => t.id === args.threadId)) { + throw new Error(`Thread ${args.threadId} does not belong to PR ${args.prId}`); + } + if (args.resolved) { + const data = await graphqlRequest<{ + resolveReviewThread?: { thread?: { id?: unknown; isResolved?: unknown } | null } | null; + }>( + ` + mutation AdeResolveReviewThread($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { + thread { id isResolved } + } + } + `, + { threadId: args.threadId }, + ); + const thread = data.resolveReviewThread?.thread ?? null; + return { + threadId: asString(thread?.id) || args.threadId, + isResolved: thread?.isResolved === true, + }; + } + const data = await graphqlRequest<{ + unresolveReviewThread?: { thread?: { id?: unknown; isResolved?: unknown } | null } | null; + }>( + ` + mutation AdeUnresolveReviewThread($threadId: ID!) { + unresolveReviewThread(input: { threadId: $threadId }) { + thread { id isResolved } + } + } + `, + { threadId: args.threadId }, + ); + const thread = data.unresolveReviewThread?.thread ?? null; + return { + threadId: asString(thread?.id) || args.threadId, + isResolved: thread?.isResolved === true, + }; + }, + + async reactToComment(args: ReactToPrCommentArgs): Promise { + // requireRow gates the caller's access to the PR, but the commentId is + // trusted from the UI: reactions can target review comments, issue + // comments, or review threads — validating ownership for every node type + // would require an extra GraphQL round-trip per click and offers little + // defense given the user already has write access to the PR's comments. + requireRow(args.prId); + const contentEnum = reactionToGraphqlEnum(args.content); + await graphqlRequest( + ` + mutation AdeReactToComment($subjectId: ID!, $content: ReactionContent!) { + addReaction(input: { subjectId: $subjectId, content: $content }) { + reaction { id content } + } + } + `, + { subjectId: args.commentId, content: contentEnum }, + ); + }, + + async getDeployments(prId: string): Promise { + const row = requireRow(prId); + const repo = repoFromRow(row); + const pr = await fetchPr(repo, Number(row.github_pr_number)); + const headSha = asString(pr?.head?.sha); + if (!headSha) return []; + const { data } = await githubService.apiRequest({ + method: "GET", + path: `/repos/${repo.owner}/${repo.name}/deployments`, + query: { sha: headSha, per_page: 100 }, + }); + const list = Array.isArray(data) ? data : []; + const deployments: PrDeployment[] = await Promise.all( + list.map(async (d): Promise => { + const deploymentId = String(asNumber(d?.id) ?? asString(d?.id) ?? ""); + let state: PrDeploymentState = "unknown"; + let environmentUrl: string | null = asString(d?.payload?.web_url) || null; + let logUrl: string | null = null; + let updatedAt: string | null = asString(d?.updated_at) || null; + try { + const { data: statuses } = await githubService.apiRequest({ + method: "GET", + path: `/repos/${repo.owner}/${repo.name}/deployments/${deploymentId}/statuses`, + query: { per_page: 1 }, + }); + const latest = Array.isArray(statuses) && statuses.length > 0 ? statuses[0] : null; + if (latest) { + state = toDeploymentState(latest.state); + environmentUrl = asString(latest.environment_url) || environmentUrl; + logUrl = asString(latest.log_url) || asString(latest.target_url) || null; + updatedAt = asString(latest.updated_at) || updatedAt; + } + } catch (err) { + logger.warn("prs.get_deployments_status_failed", { + prId, + deploymentId, + error: err instanceof Error ? err.message : String(err), + }); + } + return { + id: deploymentId || randomUUID(), + environment: asString(d?.environment) || "unknown", + state, + description: asString(d?.description) || null, + environmentUrl, + logUrl, + sha: asString(d?.sha) || headSha, + ref: asString(d?.ref) || null, + creator: asString(d?.creator?.login) || null, + createdAt: asString(d?.created_at) || null, + updatedAt, + }; + }), + ); + return deployments; + }, + async updateTitle(args: UpdatePrTitleArgs): Promise { const row = requireRow(args.prId); const repo = repoFromRow(row); diff --git a/apps/desktop/src/main/services/prs/prSummaryService.test.ts b/apps/desktop/src/main/services/prs/prSummaryService.test.ts new file mode 100644 index 000000000..5fafd2de8 --- /dev/null +++ b/apps/desktop/src/main/services/prs/prSummaryService.test.ts @@ -0,0 +1,283 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { openKvDb } from "../state/kvDb"; +import { buildPrSummaryPrompt, createPrSummaryService, parsePrSummaryJson } from "./prSummaryService"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as const; +} + +async function seed(db: any, prId: string, headSha: string | null) { + const now = "2026-04-14T00:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + ["proj", "/tmp", "ADE", "main", now, now], + ); + db.run( + ` + insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at, head_sha + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + prId, + "proj", + "lane-1", + "arul28", + "ADE", + 1, + "https://github.com/arul28/ADE/pull/1", + null, + "Test", + "open", + "main", + "feat", + "passing", + "approved", + 0, + 0, + now, + now, + now, + headSha, + ], + ); +} + +describe("buildPrSummaryPrompt", () => { + it("includes title, body, file list, unresolved count, and bot summaries", () => { + const prompt = buildPrSummaryPrompt({ + title: "Add feature", + body: "Body content", + changedFiles: [ + { filename: "a.ts", status: "modified", additions: 1, deletions: 0, patch: null, previousFilename: null }, + { filename: "b.ts", status: "added", additions: 10, deletions: 0, patch: null, previousFilename: null }, + ], + issueComments: [ + { + id: "c1", + author: "greptile-bot", + authorAvatarUrl: null, + body: "Looks risky", + source: "issue", + url: null, + path: null, + line: null, + createdAt: null, + updatedAt: null, + }, + ], + reviews: [ + { + reviewer: "coderabbitai[bot]", + reviewerAvatarUrl: null, + state: "commented", + body: "Formal bot review body", + submittedAt: null, + }, + ], + unresolvedThreadCount: 3, + }); + expect(prompt).toContain("Add feature"); + expect(prompt).toContain("Body content"); + expect(prompt).toContain("modified a.ts"); + expect(prompt).toContain("added b.ts"); + expect(prompt).toContain("Unresolved review threads: 3"); + expect(prompt).toContain("@greptile-bot"); + }); +}); + +describe("parsePrSummaryJson", () => { + it("returns fields when valid JSON provided", () => { + const result = parsePrSummaryJson( + '```json\n{"summary":"x","riskAreas":["a"],"reviewerHotspots":["b"],"unresolvedConcerns":[]}\n```', + ); + expect(result).toEqual({ + summary: "x", + riskAreas: ["a"], + reviewerHotspots: ["b"], + unresolvedConcerns: [], + }); + }); + + it("filters non-string array entries", () => { + const result = parsePrSummaryJson( + '{"summary":"s","riskAreas":["ok", 5, null],"reviewerHotspots":[],"unresolvedConcerns":[]}', + ); + expect(result?.riskAreas).toEqual(["ok"]); + }); + + it("returns null on missing JSON", () => { + expect(parsePrSummaryJson("no json here")).toBeNull(); + }); + + it("returns null on invalid JSON", () => { + expect(parsePrSummaryJson("{ not json }")).toBeNull(); + }); +}); + +describe("createPrSummaryService", () => { + it("returns null from getSummary when no cache entry exists", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-sum-get-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + try { + await seed(db, "pr-1", "headA"); + const svc = createPrSummaryService({ + db, + logger: createLogger() as any, + projectRoot: root, + prService: {} as any, + }); + await expect(svc.getSummary("pr-1")).resolves.toBeNull(); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("regenerateSummary caches the result keyed by (prId, headSha) and parses JSON", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-sum-regen-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + try { + await seed(db, "pr-1", "headA"); + + const prService = { + listAll: () => [ + { + id: "pr-1", + laneId: "lane-1", + projectId: "proj", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 1, + githubUrl: "", + githubNodeId: null, + title: "PR title", + state: "open", + baseBranch: "main", + headBranch: "feat", + checksStatus: "passing", + reviewStatus: "approved", + additions: 0, + deletions: 0, + lastSyncedAt: null, + createdAt: "", + updatedAt: "", + }, + ], + getDetail: vi.fn(async () => ({ + prId: "pr-1", + body: "Detail body", + labels: [], + assignees: [], + requestedReviewers: [], + author: { login: "arul", avatarUrl: null }, + isDraft: false, + milestone: null, + linkedIssues: [], + })), + getFiles: vi.fn(async () => [ + { filename: "x.ts", status: "modified", additions: 1, deletions: 0, patch: null, previousFilename: null }, + ]), + getComments: vi.fn(async () => []), + getReviewThreads: vi.fn(async () => [ + { + id: "t1", + isResolved: false, + isOutdated: false, + path: "x.ts", + line: 1, + originalLine: 1, + startLine: 0, + originalStartLine: 0, + diffSide: "RIGHT", + url: null, + createdAt: null, + updatedAt: null, + comments: [], + }, + ]), + getReviews: vi.fn(async () => []), + }; + + const aiIntegrationService = { + draftPrDescription: vi.fn(async () => ({ + text: '{"summary":"ok","riskAreas":["a"],"reviewerHotspots":["b"],"unresolvedConcerns":["c"]}', + durationMs: 10, + executedAt: "x", + model: "m", + provider: "openai" as const, + reasoningEffort: null, + promptTokens: null, + completionTokens: null, + totalTokens: null, + budgetState: null, + taskType: "pr_description" as const, + feature: "pr_descriptions" as const, + })), + }; + + const svc = createPrSummaryService({ + db, + logger: createLogger() as any, + projectRoot: root, + prService: prService as any, + aiIntegrationService: aiIntegrationService as any, + }); + + const result = await svc.regenerateSummary("pr-1"); + expect(result.summary).toBe("ok"); + expect(result.riskAreas).toEqual(["a"]); + expect(result.headSha).toBe("headA"); + expect(aiIntegrationService.draftPrDescription).toHaveBeenCalledTimes(1); + + const cached = await svc.getSummary("pr-1"); + expect(cached?.summary).toBe("ok"); + expect(cached?.headSha).toBe("headA"); + + // regenerateSummary bypasses cache and always calls AI + const again = await svc.regenerateSummary("pr-1"); + expect(again.summary).toBe("ok"); + expect(aiIntegrationService.draftPrDescription).toHaveBeenCalledTimes(2); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("falls back gracefully when aiIntegrationService is missing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-sum-fallback-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + try { + await seed(db, "pr-1", "headA"); + const prService = { + listAll: () => [{ id: "pr-1", title: "t" } as any], + getDetail: async () => null, + getFiles: async () => [], + getComments: async () => [], + getReviewThreads: async () => [], + getReviews: async () => [], + }; + const svc = createPrSummaryService({ + db, + logger: createLogger() as any, + projectRoot: root, + prService: prService as any, + }); + const result = await svc.regenerateSummary("pr-1"); + expect(result.summary).toMatch(/0 file/); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/prs/prSummaryService.ts b/apps/desktop/src/main/services/prs/prSummaryService.ts new file mode 100644 index 000000000..2a3fe36c7 --- /dev/null +++ b/apps/desktop/src/main/services/prs/prSummaryService.ts @@ -0,0 +1,284 @@ +import type { + PrAiSummary, + PrComment, + PrDetail, + PrFile, + PrReview, + PrReviewThread, +} from "../../../shared/types"; +import type { AdeDb } from "../state/kvDb"; +import type { Logger } from "../logging/logger"; +import type { createAiIntegrationService } from "../ai/aiIntegrationService"; +import type { createPrService } from "./prService"; +import { extractFirstJsonObject } from "../ai/utils"; +import { asString, nowIso } from "../shared/utils"; + +type PrSummaryServiceDeps = { + db: AdeDb; + logger: Logger; + projectRoot: string; + prService: ReturnType; + aiIntegrationService?: ReturnType; +}; + +type CachedSummaryRow = { + pr_id: string; + head_sha: string; + summary_json: string; + generated_at: string; +}; + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); +} + +function isBotLogin(login: string | null | undefined): boolean { + const l = (login ?? "").toLowerCase(); + return l.includes("bot") || l.includes("greptile") || l.includes("seer") || l.includes("coderabbit"); +} + +function summarizeBotReviews(comments: PrComment[], reviews: PrReview[]): string { + const commentParts = comments + .filter((c) => isBotLogin(c.author)) + .slice(0, 5) + .map((c) => `- @${c.author}: ${(c.body ?? "").slice(0, 280)}`); + const reviewParts = reviews + .filter((r) => isBotLogin(r.reviewer) && (r.body ?? "").trim().length > 0) + .slice(0, 3) + .map((r) => `- @${r.reviewer} [${r.state}]: ${(r.body ?? "").slice(0, 280)}`); + const all = [...commentParts, ...reviewParts]; + if (all.length === 0) return "(no bot reviews)"; + return all.join("\n"); +} + +export function buildPrSummaryPrompt(args: { + title: string; + body: string | null; + changedFiles: PrFile[]; + issueComments: PrComment[]; + reviews: PrReview[]; + unresolvedThreadCount: number; +}): string { + const fileList = args.changedFiles + .slice(0, 30) + .map((f) => `- ${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`) + .join("\n"); + const hidden = args.changedFiles.length > 30 ? `\n(+${args.changedFiles.length - 30} more files)` : ""; + return [ + "You are a senior reviewer preparing a pull-request briefing card.", + 'Return ONLY a JSON object with this exact shape:', + '{"summary": string, "riskAreas": string[], "reviewerHotspots": string[], "unresolvedConcerns": string[]}', + "", + "- summary: 2-3 sentences, plain English, what this PR does and why it matters.", + "- riskAreas: short phrases naming the riskiest subsystems or files to inspect.", + "- reviewerHotspots: 1-6 short bullet points reviewers should focus on.", + "- unresolvedConcerns: 0-6 items explicitly derived from bot/human review comments that are still open. Skip if none.", + "", + `PR title: ${args.title}`, + "", + "PR description:", + (args.body ?? "").slice(0, 4000) || "(empty)", + "", + "Changed files:", + fileList || "(none)", + hidden, + "", + `Unresolved review threads: ${args.unresolvedThreadCount}`, + "", + "Bot review summaries:", + summarizeBotReviews(args.issueComments, args.reviews), + "", + "Return the JSON object only. No code fences, no prose.", + ].join("\n"); +} + +export function parsePrSummaryJson(raw: string | null | undefined): Pick< + PrAiSummary, + "summary" | "riskAreas" | "reviewerHotspots" | "unresolvedConcerns" +> | null { + if (!raw) return null; + const json = extractFirstJsonObject(raw); + if (!json) return null; + try { + const obj = JSON.parse(json) as Record; + return { + summary: typeof obj.summary === "string" ? obj.summary.trim() : "", + riskAreas: toStringArray(obj.riskAreas), + reviewerHotspots: toStringArray(obj.reviewerHotspots), + unresolvedConcerns: toStringArray(obj.unresolvedConcerns), + }; + } catch { + return null; + } +} + +async function fetchPrInputs(deps: PrSummaryServiceDeps, prId: string): Promise<{ + title: string; + body: string | null; + files: PrFile[]; + issueComments: PrComment[]; + reviews: PrReview[]; + unresolvedThreadCount: number; + headSha: string | null; + detail: PrDetail | null; +}> { + const summaries = deps.prService.listAll(); + const summary = summaries.find((s) => s.id === prId); + const [detail, files, comments, threads, reviews] = await Promise.all([ + deps.prService.getDetail(prId).catch((): PrDetail | null => null), + deps.prService.getFiles(prId).catch((): PrFile[] => []), + deps.prService.getComments(prId).catch((): PrComment[] => []), + deps.prService.getReviewThreads(prId).catch((): PrReviewThread[] => []), + deps.prService.getReviews(prId).catch((): PrReview[] => []), + ]); + const unresolved = threads.filter((t) => !t.isResolved).length; + const row = deps.db.get<{ head_sha: string | null }>( + "select head_sha from pull_requests where id = ? limit 1", + [prId], + ); + const headSha = asString(row?.head_sha) || null; + return { + title: summary?.title ?? "(untitled)", + body: detail?.body ?? null, + files, + issueComments: comments, + reviews, + unresolvedThreadCount: unresolved, + headSha, + detail, + }; +} + +export function createPrSummaryService(deps: PrSummaryServiceDeps) { + const readCache = (prId: string, headSha: string): PrAiSummary | null => { + const row = deps.db.get( + "select pr_id, head_sha, summary_json, generated_at from pull_request_ai_summaries where pr_id = ? and head_sha = ? limit 1", + [prId, headSha], + ); + if (!row) return null; + try { + const parsed = JSON.parse(row.summary_json) as Partial; + return { + prId: row.pr_id, + summary: typeof parsed.summary === "string" ? parsed.summary : "", + riskAreas: toStringArray(parsed.riskAreas), + reviewerHotspots: toStringArray(parsed.reviewerHotspots), + unresolvedConcerns: toStringArray(parsed.unresolvedConcerns), + generatedAt: row.generated_at, + headSha: row.head_sha, + }; + } catch (err) { + deps.logger.warn("prs.ai_summary_cache_parse_failed", { + prId, + headSha, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + }; + + const writeCache = (summary: PrAiSummary): void => { + deps.db.run( + ` + insert or replace into pull_request_ai_summaries(pr_id, head_sha, summary_json, generated_at) + values (?, ?, ?, ?) + `, + [ + summary.prId, + summary.headSha, + JSON.stringify({ + summary: summary.summary, + riskAreas: summary.riskAreas, + reviewerHotspots: summary.reviewerHotspots, + unresolvedConcerns: summary.unresolvedConcerns, + }), + summary.generatedAt, + ], + ); + }; + + const generate = async (prId: string, options: { force?: boolean } = {}): Promise => { + const inputs = await fetchPrInputs(deps, prId); + const headSha = inputs.headSha ?? "unknown"; + if (!options.force && inputs.headSha) { + const cached = readCache(prId, inputs.headSha); + if (cached) return cached; + } + + const prompt = buildPrSummaryPrompt({ + title: inputs.title, + body: inputs.body, + changedFiles: inputs.files, + issueComments: inputs.issueComments, + reviews: inputs.reviews, + unresolvedThreadCount: inputs.unresolvedThreadCount, + }); + + if (!deps.aiIntegrationService) { + const fallback: PrAiSummary = { + prId, + summary: `This PR modifies ${inputs.files.length} file(s).`, + riskAreas: [], + reviewerHotspots: [], + unresolvedConcerns: + inputs.unresolvedThreadCount > 0 + ? [`${inputs.unresolvedThreadCount} unresolved review threads`] + : [], + generatedAt: nowIso(), + headSha, + }; + if (inputs.headSha) writeCache(fallback); + return fallback; + } + + try { + const result = await deps.aiIntegrationService.draftPrDescription({ + laneId: "", // aiIntegrationService accepts empty laneId for one-shot tasks; uses projectRoot cwd. + cwd: deps.projectRoot, + prompt, + }); + const parsed = parsePrSummaryJson(result.text); + const summary: PrAiSummary = { + prId, + summary: parsed?.summary ?? "AI summary unavailable.", + riskAreas: parsed?.riskAreas ?? [], + reviewerHotspots: parsed?.reviewerHotspots ?? [], + unresolvedConcerns: parsed?.unresolvedConcerns ?? [], + generatedAt: nowIso(), + headSha, + }; + if (inputs.headSha) writeCache(summary); + return summary; + } catch (err) { + deps.logger.warn("prs.ai_summary_generate_failed", { + prId, + error: err instanceof Error ? err.message : String(err), + }); + const fallback: PrAiSummary = { + prId, + summary: "AI summary failed. Try regenerating.", + riskAreas: [], + reviewerHotspots: [], + unresolvedConcerns: [], + generatedAt: nowIso(), + headSha, + }; + return fallback; + } + }; + + return { + getSummary: async (prId: string): Promise => { + const row = deps.db.get<{ head_sha: string | null }>( + "select head_sha from pull_requests where id = ? limit 1", + [prId], + ); + const headSha = asString(row?.head_sha) || null; + if (!headSha) return null; + return readCache(prId, headSha); + }, + regenerateSummary: async (prId: string): Promise => generate(prId, { force: true }), + ensureSummary: async (prId: string): Promise => generate(prId, { force: false }), + }; +} diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts index 911fc215b..1ee33dc5d 100644 --- a/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts +++ b/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts @@ -2,15 +2,23 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; import { resolveDesktopAdeMcpLaunch, resolveRepoRuntimeRoot } from "./adeMcpLaunch"; const originalResourcesPath = process.resourcesPath; +const originalExecPath = process.execPath; +const originalPath = process.env.PATH; afterEach(() => { Object.defineProperty(process, "resourcesPath", { configurable: true, value: originalResourcesPath, }); + Object.defineProperty(process, "execPath", { + configurable: true, + value: originalExecPath, + }); + process.env.PATH = originalPath; }); describe("resolveDesktopAdeMcpLaunch", () => { @@ -29,7 +37,7 @@ describe("resolveDesktopAdeMcpLaunch", () => { }); expect(launch.mode).toBe("bundled_proxy"); - expect(launch.command).toBe(process.execPath); + expect(launch.command).toBe(resolveExecutableFromKnownLocations("node")?.path ?? process.argv0 ?? process.execPath); expect(launch.cmdArgs).toEqual([ path.resolve(proxyEntry), "--project-root", @@ -314,6 +322,33 @@ describe("resolveDesktopAdeMcpLaunch", () => { expect(launch.mode).toBe("bundled_proxy"); expect(launch.runtimeRoot).toBeNull(); }); + + it("falls back to a resolvable node executable when process.execPath is stale", () => { + const tempBinDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-node-bin-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-stale-")); + const workspaceRoot = path.join(projectRoot, "workspace"); + const proxyEntry = path.join(projectRoot, "dist", "main", "adeMcpProxy.cjs"); + const fakeNode = path.join(tempBinDir, "node"); + + fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.mkdirSync(path.dirname(proxyEntry), { recursive: true }); + fs.writeFileSync(proxyEntry, "module.exports = {};\n", "utf8"); + fs.writeFileSync(fakeNode, "#!/bin/sh\nexit 0\n", { encoding: "utf8", mode: 0o755 }); + Object.defineProperty(process, "execPath", { + configurable: true, + value: path.join(tempBinDir, "missing-node"), + }); + process.env.PATH = `${tempBinDir}${path.delimiter}${originalPath ?? ""}`; + + const launch = resolveDesktopAdeMcpLaunch({ + projectRoot, + workspaceRoot, + bundledProxyPath: proxyEntry, + }); + + expect(launch.mode).toBe("bundled_proxy"); + expect(launch.command).toBe(fakeNode); + }); }); describe("resolveRepoRuntimeRoot", () => { diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts index 42f965b30..309700db8 100644 --- a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts +++ b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import type { ComputerUsePolicy } from "../../../shared/types"; +import { resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; export type AdeMcpLaunchMode = "bundled_proxy" | "headless_built" | "headless_source"; export type AdeMcpWorkspaceBinding = "explicit" | "project_root"; @@ -76,6 +77,21 @@ function resolveBundledProxyPath(overridePath?: string): string | null { return null; } +function resolveBundledProxyCommand(packaged: boolean): string { + if (packaged && pathExists(process.execPath)) { + return process.execPath; + } + const resolvedNode = resolveExecutableFromKnownLocations("node")?.path; + if (resolvedNode) { + return resolvedNode; + } + const argv0 = typeof process.argv0 === "string" ? process.argv0.trim() : ""; + if (argv0.length > 0) { + return argv0; + } + return process.execPath; +} + export function resolveRepoRuntimeRoot(): string { const startPoints = [ process.cwd(), @@ -154,7 +170,7 @@ export function resolveDesktopAdeMcpLaunch(args: DesktopAdeMcpLaunchArgs): AdeMc if (bundledProxyPath) { return { mode: "bundled_proxy", - command: process.execPath, + command: resolveBundledProxyCommand(packaged), cmdArgs: [bundledProxyPath, "--project-root", projectRoot, "--workspace-root", workspaceRoot], env: { ...env, diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 2cfc13e00..0951767e0 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -216,6 +216,32 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + it("hard deletes a stored session row", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "session-delete", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Disposable chat", + startedAt: "2026-03-17T00:10:00.000Z", + transcriptPath: "/tmp/session-delete.log", + toolType: "opencode-chat", + }); + + expect(service.get("session-delete")?.id).toBe("session-delete"); + expect(service.deleteSession("session-delete")).toBe(true); + expect(service.get("session-delete")).toBeNull(); + expect(service.deleteSession("session-delete")).toBe(false); + + activeDisposers.push(async () => db.close()); + }); + it("reattaches an existing tracked session to a new PTY without changing its identity", async () => { const projectRoot = makeProjectRoot("ade-session-service-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); @@ -300,4 +326,38 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + + it("reconciles stale running chat sessions when no exclusions are provided", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "session-chat-stale", + laneId: "lane-1", + ptyId: "pty-chat-stale", + tracked: true, + title: "Claude chat", + startedAt: "2026-03-17T00:10:00.000Z", + transcriptPath: "/tmp/session-chat-stale.log", + toolType: "claude-chat", + }); + + const reconciled = service.reconcileStaleRunningSessions({ + endedAt: "2026-03-17T00:20:00.000Z", + status: "disposed", + }); + + expect(reconciled).toBe(1); + expect(service.get("session-chat-stale")).toEqual(expect.objectContaining({ + id: "session-chat-stale", + ptyId: null, + status: "disposed", + endedAt: "2026-03-17T00:20:00.000Z", + })); + + activeDisposers.push(async () => db.close()); + }); }); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 81a261705..87f7e28d1 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -544,6 +544,19 @@ export function createSessionService({ db }: { db: AdeDb }) { ]); }, + deleteSession(sessionId: string): boolean { + const trimmed = sessionId.trim(); + if (!trimmed) return false; + const existing = db.get<{ present: number }>( + "select 1 as present from terminal_sessions where id = ? limit 1", + [trimmed], + ); + if (!existing) return false; + db.run("delete from terminal_sessions where id = ?", [trimmed]); + emitChanged({ sessionId: trimmed, reason: "deleted" }); + return true; + }, + async readTranscriptTail( transcriptPath: string, maxBytes: number, diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 469a3e2b7..d5d0aa728 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -989,6 +989,21 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { `); db.run("create index if not exists idx_pull_requests_lane_id on pull_requests(lane_id)"); db.run("create index if not exists idx_pull_requests_project_id on pull_requests(project_id)"); + try { db.run("alter table pull_requests add column last_polled_at text"); } catch {} + try { db.run("alter table pull_requests add column head_sha text"); } catch {} + + // Phase 21: AI PR summary cache (keyed by PR + headSha so pushes invalidate). + db.run(` + create table if not exists pull_request_ai_summaries ( + pr_id text not null, + head_sha text not null, + summary_json text not null, + generated_at text not null, + primary key(pr_id, head_sha), + foreign key(pr_id) references pull_requests(id) + ) + `); + db.run("create index if not exists idx_pr_ai_summaries_pr_id on pull_request_ai_summaries(pr_id)"); db.run(` create table if not exists pull_request_snapshots ( diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 914a83aa3..e09139fd0 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -63,6 +63,7 @@ import type { AgentTool, AgentChatApproveArgs, AgentChatCreateArgs, + AgentChatDeleteArgs, AgentChatDisposeArgs, AgentChatEventEnvelope, AgentChatGetSummaryArgs, @@ -309,6 +310,14 @@ import type { PrStatus, PrSummary, PrWithConflicts, + PrDeployment, + PrAiSummary, + PostPrReviewCommentArgs, + SetPrReviewThreadResolvedArgs, + SetPrReviewThreadResolvedResult, + ReactToPrCommentArgs, + LaunchPrIssueResolutionFromThreadArgs, + LaunchPrIssueResolutionFromThreadResult, ReplyToPrReviewThreadArgs, ResolvePrReviewThreadArgs, ResumeQueueAutomationArgs, @@ -341,6 +350,7 @@ import type { ListLanesArgs, ListOperationsArgs, ListSessionsArgs, + DeleteSessionArgs, ListTestRunsArgs, OperationRecord, ProcessActionArgs, @@ -561,9 +571,11 @@ import type { ComputerUseOwnerSnapshot, ComputerUseOwnerSnapshotArgs, ComputerUseSettingsSnapshot, - FeedbackSubmitArgs, + FeedbackPrepareDraftArgs, + FeedbackPreparedDraft, FeedbackSubmission, FeedbackSubmissionEvent, + FeedbackSubmitDraftArgs, } from "../shared/types"; export {}; @@ -1057,6 +1069,7 @@ declare global { sessions: { list: (args?: ListSessionsArgs) => Promise; get: (sessionId: string) => Promise; + delete: (args: DeleteSessionArgs) => Promise; updateMeta: (args: UpdateSessionMetaArgs) => Promise; readTranscriptTail: (args: ReadTranscriptTailArgs) => Promise; getDelta: (sessionId: string) => Promise; @@ -1081,6 +1094,7 @@ declare global { respondToInput: (args: AgentChatRespondToInputArgs) => Promise; models: (args: AgentChatModelsArgs) => Promise; dispose: (args: AgentChatDisposeArgs) => Promise; + delete: (args: AgentChatDeleteArgs) => Promise; updateSession: ( args: AgentChatUpdateSessionArgs, ) => Promise; @@ -1271,7 +1285,8 @@ declare global { onStatusChanged: (cb: (status: ContextStatus) => void) => () => void; }; feedback: { - submit: (args: FeedbackSubmitArgs) => Promise; + prepareDraft: (args: FeedbackPrepareDraftArgs) => Promise; + submitDraft: (args: FeedbackSubmitDraftArgs) => Promise; list: () => Promise; onUpdate: (cb: (event: FeedbackSubmissionEvent) => void) => () => void; }; @@ -1442,6 +1457,19 @@ declare global { cleanupIntegrationWorkflow: ( args: CleanupIntegrationWorkflowArgs, ) => Promise; + getDeployments: (prId: string) => Promise; + getAiSummary: (prId: string) => Promise; + regenerateAiSummary: (prId: string) => Promise; + postReviewComment: ( + args: PostPrReviewCommentArgs, + ) => Promise; + setReviewThreadResolved: ( + args: SetPrReviewThreadResolvedArgs, + ) => Promise; + reactToComment: (args: ReactToPrCommentArgs) => Promise; + launchIssueResolutionFromThread: ( + args: LaunchPrIssueResolutionFromThreadArgs, + ) => Promise; }; rebase: { scanNeeds: () => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 98f36ff60..c33a8ad83 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -202,6 +202,14 @@ import type { AddPrCommentArgs, ReplyToPrReviewThreadArgs, ResolvePrReviewThreadArgs, + PrDeployment, + PrAiSummary, + PostPrReviewCommentArgs, + SetPrReviewThreadResolvedArgs, + SetPrReviewThreadResolvedResult, + ReactToPrCommentArgs, + LaunchPrIssueResolutionFromThreadArgs, + LaunchPrIssueResolutionFromThreadResult, UpdatePrTitleArgs, UpdatePrBodyArgs, SetPrLabelsArgs, @@ -233,6 +241,7 @@ import type { AgentTool, AgentChatApproveArgs, AgentChatCreateArgs, + AgentChatDeleteArgs, AgentChatDisposeArgs, AgentChatEventEnvelope, AgentChatGetSummaryArgs, @@ -274,6 +283,7 @@ import type { ImportBranchLaneArgs, ListOperationsArgs, ListSessionsArgs, + DeleteSessionArgs, ListTestRunsArgs, MergeSimulationArgs, MergeSimulationResult, @@ -565,9 +575,11 @@ import type { ComputerUseOwnerSnapshot, ComputerUseOwnerSnapshotArgs, ComputerUseSettingsSnapshot, - FeedbackSubmitArgs, + FeedbackPrepareDraftArgs, + FeedbackPreparedDraft, FeedbackSubmission, FeedbackSubmissionEvent, + FeedbackSubmitDraftArgs, } from "../shared/types"; contextBridge.exposeInMainWorld("ade", { @@ -1478,6 +1490,8 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.sessionsList, args), get: async (sessionId: string): Promise => ipcRenderer.invoke(IPC.sessionsGet, { sessionId }), + delete: async (args: DeleteSessionArgs): Promise => + ipcRenderer.invoke(IPC.sessionsDelete, args), updateMeta: async (args: UpdateSessionMetaArgs): Promise => ipcRenderer.invoke(IPC.sessionsUpdateMeta, args), readTranscriptTail: async (args: ReadTranscriptTailArgs): Promise => @@ -1528,6 +1542,8 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.agentChatModels, args), dispose: async (args: AgentChatDisposeArgs): Promise => ipcRenderer.invoke(IPC.agentChatDispose, args), + delete: async (args: AgentChatDeleteArgs): Promise => + ipcRenderer.invoke(IPC.agentChatDelete, args), updateSession: async ( args: AgentChatUpdateSessionArgs, ): Promise => @@ -1863,8 +1879,10 @@ contextBridge.exposeInMainWorld("ade", { }, }, feedback: { - submit: async (args: FeedbackSubmitArgs): Promise => - ipcRenderer.invoke(IPC.feedbackSubmit, args), + prepareDraft: async (args: FeedbackPrepareDraftArgs): Promise => + ipcRenderer.invoke(IPC.feedbackPrepareDraft, args), + submitDraft: async (args: FeedbackSubmitDraftArgs): Promise => + ipcRenderer.invoke(IPC.feedbackSubmitDraft, args), list: async (): Promise => ipcRenderer.invoke(IPC.feedbackList), onUpdate: (cb: (event: FeedbackSubmissionEvent) => void): (() => void) => { @@ -2139,6 +2157,26 @@ contextBridge.exposeInMainWorld("ade", { args: CleanupIntegrationWorkflowArgs, ): Promise => ipcRenderer.invoke(IPC.prsCleanupIntegrationWorkflow, args), + getDeployments: async (prId: string): Promise => + ipcRenderer.invoke(IPC.prsGetDeployments, { prId }), + getAiSummary: async (prId: string): Promise => + ipcRenderer.invoke(IPC.prsGetAiSummary, { prId }), + regenerateAiSummary: async (prId: string): Promise => + ipcRenderer.invoke(IPC.prsRegenerateAiSummary, { prId }), + postReviewComment: async ( + args: PostPrReviewCommentArgs, + ): Promise => + ipcRenderer.invoke(IPC.prsPostReviewComment, args), + setReviewThreadResolved: async ( + args: SetPrReviewThreadResolvedArgs, + ): Promise => + ipcRenderer.invoke(IPC.prsSetReviewThreadResolved, args), + reactToComment: async (args: ReactToPrCommentArgs): Promise => + ipcRenderer.invoke(IPC.prsReactToComment, args), + launchIssueResolutionFromThread: async ( + args: LaunchPrIssueResolutionFromThreadArgs, + ): Promise => + ipcRenderer.invoke(IPC.prsLaunchIssueResolutionFromThread, args), }, rebase: { scanNeeds: async (): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 258c7db07..c6006d92d 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -1640,9 +1640,17 @@ if (typeof window !== "undefined" && !(window as any).ade) { app: { ping: resolved("pong" as const), getInfo: resolved({ - version: "0.0.0-browser", + appVersion: "0.0.0-browser", + isPackaged: false, platform: "browser", arch: "web", + versions: { + electron: "0.0.0-browser", + chrome: "0.0.0-browser", + node: "0.0.0-browser", + v8: "0.0.0-browser", + }, + env: {}, }), getProject: resolved(MOCK_PROJECT), onProjectChanged: () => () => {}, @@ -2322,9 +2330,11 @@ if (typeof window !== "undefined" && !(window as any).ade) { sessions: { list: resolved([]), get: resolvedArg(null), + delete: resolvedArg(undefined), updateMeta: resolvedArg(null), readTranscriptTail: resolvedArg(""), getDelta: resolvedArg(null), + onChanged: noop, }, agentChat: { list: resolved([]), @@ -2337,6 +2347,7 @@ if (typeof window !== "undefined" && !(window as any).ade) { respondToInput: resolvedArg(undefined), models: resolvedArg([]), dispose: resolvedArg(undefined), + delete: resolvedArg(undefined), updateSession: resolvedArg({ id: "mock" }), onEvent: noop, slashCommands: resolvedArg([]), @@ -2646,7 +2657,17 @@ if (typeof window !== "undefined" && !(window as any).ade) { discardFile: resolvedArg({ ok: true }), restoreStagedFile: resolvedArg({ ok: true }), commit: resolvedArg({ ok: true }), - listRecentCommits: resolvedArg([]), + listRecentCommits: resolvedArg([ + { + sha: "abcdef1234567890", + shortSha: "abcdef1", + parents: [], + authorName: "ADE Browser Mock", + authoredAt: now, + subject: "Browser mock HEAD commit", + pushed: true, + }, + ]), listCommitFiles: resolvedArg([]), getCommitMessage: resolvedArg(""), revertCommit: resolvedArg({ ok: true }), @@ -2697,12 +2718,34 @@ if (typeof window !== "undefined" && !(window as any).ade) { openDoc: resolvedArg(undefined), }, feedback: { - submit: resolvedArg({ + prepareDraft: resolvedArg({ + category: "bug", + draftInput: { + category: "bug", + summary: "Mock feedback", + stepsToReproduce: "", + expectedBehavior: "", + actualBehavior: "", + environment: "", + additionalContext: "", + }, + userDescription: "## Summary\n\nMock feedback", + modelId: null, + reasoningEffort: null, + title: "Mock feedback", + body: "## Description\n\nMock feedback", + labels: ["bug"], + generationMode: "deterministic", + generationWarning: "ADE used a deterministic draft because no AI model was selected.", + }), + submitDraft: resolvedArg({ id: "mock-feedback-1", category: "bug", userDescription: "Mock feedback", - modelId: "claude-sonnet-4-6", - status: "pending", + modelId: null, + status: "posted", + generationMode: null, + generationWarning: null, generatedTitle: null, generatedBody: null, issueUrl: null, diff --git a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.test.tsx b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.test.tsx index d76e7916d..b72c66b9b 100644 --- a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.test.tsx +++ b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.test.tsx @@ -5,6 +5,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FeedbackReporterModal } from "./FeedbackReporterModal"; +import { useAppStore } from "../../state/appStore"; vi.mock("motion/react", () => ({ AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, @@ -41,6 +42,8 @@ describe("FeedbackReporterModal", () => { userDescription: "The report failed and I need to see what I originally submitted.", modelId: "anthropic/claude-opus-4-6", status: "failed", + generationMode: "deterministic", + generationWarning: "ADE used a deterministic draft because AI title and label suggestion failed: GitHub API unavailable", generatedTitle: null, generatedBody: null, issueUrl: null, @@ -56,6 +59,8 @@ describe("FeedbackReporterModal", () => { userDescription: "Please add a way to expand the previous submissions tab.", modelId: "anthropic/claude-opus-4-6", status: "posted", + generationMode: "ai_assisted", + generationWarning: null, generatedTitle: "Expandable submissions in feedback reporter", generatedBody: "## Description\n\nLet users inspect the saved payload and error state.", issueUrl: "https://github.com/arul28/ADE/issues/144", @@ -68,16 +73,118 @@ describe("FeedbackReporterModal", () => { ]; beforeEach(() => { + useAppStore.setState({ + project: { + rootPath: "/Users/admin/Projects/ADE", + displayName: "ADE", + baseRef: "main", + }, + lanes: [ + { + id: "lane-feedback", + name: "feedback-reporter", + description: null, + laneType: "worktree", + baseRef: "main", + branchRef: "refs/heads/feature/feedback-reporter", + worktreePath: "/Users/admin/Projects/ADE/.ade/worktrees/feedback-reporter", + attachedRootPath: null, + parentLaneId: "lane-main", + childCount: 0, + stackDepth: 1, + parentStatus: null, + isEditProtected: false, + status: { + dirty: true, + ahead: 2, + behind: 0, + remoteBehind: 0, + rebaseInProgress: false, + }, + color: null, + icon: null, + tags: [], + createdAt: "2026-04-08T05:00:00.000Z", + archivedAt: null, + }, + ] as any, + selectedLaneId: "lane-feedback", + }); + const prepareDraft = vi.fn(async () => ({ + category: "bug", + draftInput: { + category: "bug", + summary: "The feedback reporter should show a preview before posting.", + stepsToReproduce: "1. Open the reporter", + expectedBehavior: "See a preview", + actualBehavior: "It posts immediately", + environment: "ADE Desktop", + additionalContext: "", + }, + userDescription: "## Summary\n\nThe feedback reporter should show a preview before posting.", + modelId: null, + reasoningEffort: null, + title: "Show a preview before posting feedback issues", + body: "## Description\n\nThe feedback reporter should show a preview before posting.", + labels: ["bug"], + generationMode: "deterministic", + generationWarning: "ADE used a deterministic draft because no AI model was selected. Review the generated title and labels before posting.", + })); + const submitDraft = vi.fn(async () => ({ + id: "posted-2", + category: "bug", + userDescription: "## Summary\n\nThe feedback reporter should show a preview before posting.", + modelId: null, + status: "posted", + generationMode: "deterministic", + generationWarning: "ADE used a deterministic draft because no AI model was selected. Review the generated title and labels before posting.", + generatedTitle: "Show a preview before posting feedback issues", + generatedBody: "## Description\n\nThe feedback reporter should show a preview before posting.", + issueUrl: "https://github.com/arul28/ADE/issues/145", + issueNumber: 145, + issueState: "open", + error: null, + createdAt: "2026-04-08T05:30:00.000Z", + completedAt: "2026-04-08T05:30:02.000Z", + })); + globalThis.window.ade = { github: { getStatus: vi.fn(async () => ({ tokenStored: true })), }, + git: { + listRecentCommits: vi.fn(async () => [ + { + sha: "abc1234567890", + shortSha: "abc1234", + parents: [], + authorName: "ADE Test", + authoredAt: "2026-04-08T05:29:00.000Z", + subject: "Tighten feedback reporter preview flow", + pushed: false, + }, + ]), + }, feedback: { list: vi.fn(async () => submissions), onUpdate: vi.fn(() => () => {}), - submit: vi.fn(), + prepareDraft, + submitDraft, }, app: { + getInfo: vi.fn(async () => ({ + appVersion: "1.2.3", + isPackaged: false, + platform: "darwin", + arch: "arm64", + versions: { + electron: "38.0.0", + chrome: "138.0.0.0", + node: "22.16.0", + v8: "13.8.0.0", + }, + env: {}, + })), openExternal: vi.fn(async () => undefined), }, } as any; @@ -85,6 +192,11 @@ describe("FeedbackReporterModal", () => { afterEach(() => { cleanup(); + useAppStore.setState({ + project: null, + lanes: [], + selectedLaneId: null, + }); if (originalAde === undefined) { delete (globalThis.window as any).ade; } else { @@ -102,6 +214,10 @@ describe("FeedbackReporterModal", () => { fireEvent.click(screen.getByRole("button", { name: /my submissions/i })); expect(await screen.findByText(/Posting failed: GitHub API unavailable/i)).toBeTruthy(); + expect(screen.getAllByText(/Deterministic/i).length).toBeGreaterThan(0); + expect( + screen.getByText(/ADE used a deterministic draft because AI title and label suggestion failed/i), + ).toBeTruthy(); expect( screen.getByText(/The report failed and I need to see what I originally submitted\./i, { selector: "div", @@ -125,4 +241,35 @@ describe("FeedbackReporterModal", () => { screen.getByText(/Let users inspect the saved payload and error state\./i), ).toBeTruthy(); }); + + it("prepares a draft from structured fields and posts the reviewed issue", async () => { + render( + + + , + ); + + fireEvent.change( + await screen.findByPlaceholderText(/what changed, what broke/i), + { target: { value: "The feedback reporter should show a preview before posting." } }, + ); + + await waitFor(() => { + expect(screen.getByDisplayValue(/ADE version: 1\.2\.3 \(dev\)/i)).toBeTruthy(); + }); + expect(screen.getByDisplayValue(/Selected lane: feedback-reporter/i)).toBeTruthy(); + expect(screen.getByDisplayValue(/HEAD commit: abc1234 Tighten feedback reporter preview flow/i)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /generate draft/i })); + + expect(await screen.findByText(/draft preview/i)).toBeTruthy(); + expect(screen.getByDisplayValue(/Show a preview before posting feedback issues/i)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /post to github/i })); + + await waitFor(() => { + expect((window.ade.feedback.submitDraft as any)).toHaveBeenCalledTimes(1); + }); + expect(await screen.findByText(/Posted issue #145\./i)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx index 18376f992..a86f1a493 100644 --- a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx +++ b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx @@ -16,10 +16,17 @@ import { import { ProviderModelSelector } from "../shared/ProviderModelSelector"; import { useAppStore } from "../../state/appStore"; import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; +import type { AppInfo, ProjectInfo } from "../../../shared/types/core"; +import type { GitCommitSummary } from "../../../shared/types/git"; +import type { LaneSummary } from "../../../shared/types/lanes"; import type { FeedbackCategory, + FeedbackDraftInput, + FeedbackGenerationMode, + FeedbackPreparedDraft, + FeedbackPrepareDraftArgs, FeedbackSubmission, - FeedbackSubmitArgs, + FeedbackSubmitDraftArgs, } from "../../../shared/types/feedback"; const CATEGORIES: { value: FeedbackCategory; label: string }[] = [ @@ -46,6 +53,14 @@ const INPUT_STYLE: React.CSSProperties = { borderRadius: 0, }; +const TEXTAREA_STYLE: React.CSSProperties = { + ...INPUT_STYLE, + padding: "8px 12px", + fontSize: 12, + resize: "none", + outline: "none", +}; + function categoryIcon(cat: FeedbackCategory, size = 12) { switch (cat) { case "bug": @@ -100,6 +115,145 @@ function formatSubmissionTimestamp(value: string | null): string { return date.toLocaleString(); } +function formattingLabel(mode: FeedbackGenerationMode | null): { text: string; color: string } | null { + switch (mode) { + case "ai_assisted": + return { text: "AI assisted", color: COLORS.success }; + case "deterministic": + return { text: "Deterministic", color: COLORS.warning }; + default: + return null; + } +} + +type DraftFormState = { + summary: string; + additionalContext: string; + stepsToReproduce: string; + expectedBehavior: string; + actualBehavior: string; + environment: string; + useCase: string; + proposedSolution: string; + alternativesConsidered: string; + context: string; + expectedGuidance: string; +}; + +function createEmptyDraftForm(): DraftFormState { + return { + summary: "", + additionalContext: "", + stepsToReproduce: "", + expectedBehavior: "", + actualBehavior: "", + environment: "", + useCase: "", + proposedSolution: "", + alternativesConsidered: "", + context: "", + expectedGuidance: "", + }; +} + +function buildDraftInput(category: FeedbackCategory, form: DraftFormState): FeedbackDraftInput { + switch (category) { + case "bug": + return { + category: "bug", + summary: form.summary, + stepsToReproduce: form.stepsToReproduce, + expectedBehavior: form.expectedBehavior, + actualBehavior: form.actualBehavior, + environment: form.environment, + additionalContext: form.additionalContext, + }; + case "feature": + case "enhancement": + return { + category, + summary: form.summary, + useCase: form.useCase, + proposedSolution: form.proposedSolution, + alternativesConsidered: form.alternativesConsidered, + additionalContext: form.additionalContext, + }; + case "question": + return { + category: "question", + summary: form.summary, + context: form.context, + expectedGuidance: form.expectedGuidance, + additionalContext: form.additionalContext, + }; + } +} + +function labelsToInputValue(labels: string[]): string { + return labels.join(", "); +} + +function parseLabelInput(value: string): string[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function helperText(text: string) { + return ( +
+ {text} +
+ ); +} + +function simplifyRef(ref: string | null | undefined): string { + const trimmed = typeof ref === "string" ? ref.trim() : ""; + if (!trimmed) return "Unknown"; + return trimmed + .replace(/^refs\/heads\//, "") + .replace(/^refs\/remotes\//, ""); +} + +function describeLaneStatus(lane: LaneSummary): string { + const parts = [lane.status.dirty ? "dirty worktree" : "clean worktree"]; + if (lane.status.ahead > 0) parts.push(`ahead ${lane.status.ahead}`); + if (lane.status.behind > 0) parts.push(`behind ${lane.status.behind}`); + if (lane.status.remoteBehind > 0) parts.push(`remote ahead ${lane.status.remoteBehind}`); + if (lane.status.rebaseInProgress) parts.push("rebase in progress"); + return parts.join(", "); +} + +function buildAutoEnvironmentText(args: { + appInfo: AppInfo | null; + project: ProjectInfo | null; + selectedLane: LaneSummary | null; + headCommit: GitCommitSummary | null; +}): string { + const lines: string[] = []; + if (args.appInfo) { + const version = args.appInfo.appVersion.trim(); + lines.push(`ADE version: ${version}${args.appInfo.isPackaged ? "" : " (dev)"}`); + lines.push(`Platform: ${args.appInfo.platform} ${args.appInfo.arch}`); + lines.push(`Electron: ${args.appInfo.versions.electron}`); + } + if (args.project) { + lines.push(`Project: ${args.project.displayName}`); + lines.push(`Project base ref: ${simplifyRef(args.project.baseRef)}`); + } + if (args.selectedLane) { + lines.push(`Selected lane: ${args.selectedLane.name}`); + lines.push(`Lane branch: ${simplifyRef(args.selectedLane.branchRef)}`); + lines.push(`Lane base ref: ${simplifyRef(args.selectedLane.baseRef)}`); + lines.push(`Lane status: ${describeLaneStatus(args.selectedLane)}`); + } + if (args.headCommit) { + lines.push(`HEAD commit: ${args.headCommit.shortSha} ${args.headCommit.subject}`); + } + return lines.join("\n"); +} + function NewReportTab({ hasGithubToken, onSubmitted, @@ -111,42 +265,179 @@ function NewReportTab({ const openAiProvidersSettings = useCallback(() => { navigate("/settings?tab=ai#ai-providers"); }, [navigate]); + const project = useAppStore((s) => s.project); + const lanes = useAppStore((s) => s.lanes); + const selectedLaneId = useAppStore((s) => s.selectedLaneId); const availableModels = useAppStore((s) => s.availableModels); const availableModelIds = availableModels.map((m) => m.id); + const selectedLane = (selectedLaneId + ? lanes.find((lane) => lane.id === selectedLaneId) + : lanes[0]) ?? null; const [category, setCategory] = useState("bug"); - const [description, setDescription] = useState(""); + const [form, setForm] = useState(() => createEmptyDraftForm()); const [modelId, setModelId] = useState(""); const [reasoningEffort, setReasoningEffort] = useState(null); + const [preparedDraft, setPreparedDraft] = useState(null); + const [draftTitle, setDraftTitle] = useState(""); + const [draftBody, setDraftBody] = useState(""); + const [draftLabelsText, setDraftLabelsText] = useState(""); + const [preparing, setPreparing] = useState(false); const [submitting, setSubmitting] = useState(false); const [flash, setFlash] = useState<{ msg: string; ok: boolean } | null>(null); + const [autoEnvironmentText, setAutoEnvironmentText] = useState(""); const flashTimer = useRef(null); + const lastAppliedEnvironmentRef = useRef(""); + + const clearPreparedDraft = useCallback(() => { + setPreparedDraft(null); + setDraftTitle(""); + setDraftBody(""); + setDraftLabelsText(""); + }, []); + + const setFlashMessage = useCallback((msg: string, ok: boolean, timeoutMs: number) => { + setFlash({ msg, ok }); + if (flashTimer.current) window.clearTimeout(flashTimer.current); + flashTimer.current = window.setTimeout(() => setFlash(null), timeoutMs); + }, []); + + const updateField = useCallback((key: keyof DraftFormState, value: string) => { + setForm((prev) => ({ ...prev, [key]: value })); + clearPreparedDraft(); + }, [clearPreparedDraft]); + + const applyAutoEnvironment = useCallback((nextEnvironment: string) => { + const trimmed = nextEnvironment.trim(); + if (!trimmed) return; + lastAppliedEnvironmentRef.current = trimmed; + let shouldClearDraft = false; + setForm((prev) => { + if (prev.environment === trimmed) return prev; + shouldClearDraft = true; + return { ...prev, environment: trimmed }; + }); + if (shouldClearDraft) clearPreparedDraft(); + }, [clearPreparedDraft]); + + useEffect(() => { + let cancelled = false; + async function hydrateEnvironment() { + const [appInfo, latestCommit] = await Promise.all([ + window.ade.app.getInfo().catch(() => null), + selectedLane + ? window.ade.git + .listRecentCommits({ laneId: selectedLane.id, limit: 1 }) + .then((commits) => commits[0] ?? null) + .catch(() => null) + : Promise.resolve(null), + ]); + if (cancelled) return; + const nextEnvironment = buildAutoEnvironmentText({ + appInfo, + project, + selectedLane, + headCommit: latestCommit, + }).trim(); + setAutoEnvironmentText(nextEnvironment); + let shouldClearDraft = false; + setForm((prev) => { + const currentEnvironment = prev.environment.trim(); + const lastAppliedEnvironment = lastAppliedEnvironmentRef.current.trim(); + if (!nextEnvironment) return prev; + if (currentEnvironment.length === 0 || currentEnvironment === lastAppliedEnvironment) { + lastAppliedEnvironmentRef.current = nextEnvironment; + if (prev.environment === nextEnvironment) return prev; + shouldClearDraft = true; + return { ...prev, environment: nextEnvironment }; + } + return prev; + }); + if (shouldClearDraft) clearPreparedDraft(); + } + void hydrateEnvironment(); + return () => { + cancelled = true; + }; + }, [ + clearPreparedDraft, + project, + selectedLane, + ]); - const handleSubmit = useCallback(async () => { - if (!description.trim() || !modelId || submitting) return; + const handleGenerateDraft = useCallback(async () => { + if (!form.summary.trim() || preparing || submitting) return; + setPreparing(true); + try { + const args: FeedbackPrepareDraftArgs = { + draftInput: buildDraftInput(category, form), + modelId: modelId.trim() || null, + reasoningEffort: modelId.trim() ? reasoningEffort : null, + }; + const nextDraft = await window.ade.feedback.prepareDraft(args); + setPreparedDraft(nextDraft); + setDraftTitle(nextDraft.title); + setDraftBody(nextDraft.body); + setDraftLabelsText(labelsToInputValue(nextDraft.labels)); + setFlashMessage("Draft ready. Review and edit it before posting to GitHub.", true, 4000); + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "Failed to prepare feedback draft"; + setFlashMessage(msg, false, 6000); + } finally { + setPreparing(false); + } + }, [category, form, modelId, preparing, reasoningEffort, setFlashMessage, submitting]); + + const handleSubmitDraft = useCallback(async () => { + if (!preparedDraft || !draftTitle.trim() || !draftBody.trim() || preparing || submitting) return; setSubmitting(true); try { - const args: FeedbackSubmitArgs = { - category, - userDescription: description.trim(), - modelId, - reasoningEffort, + const args: FeedbackSubmitDraftArgs = { + draft: preparedDraft, + title: draftTitle.trim(), + body: draftBody.trim(), + labels: parseLabelInput(draftLabelsText), }; - await window.ade.feedback.submit(args); - setDescription(""); - setFlash({ msg: "Submitted! Report is generating in the background.", ok: true }); - if (flashTimer.current) window.clearTimeout(flashTimer.current); - flashTimer.current = window.setTimeout(() => setFlash(null), 4000); - onSubmitted(); + const submission = await window.ade.feedback.submitDraft(args); + if (submission.status === "posted") { + setForm({ ...createEmptyDraftForm(), environment: autoEnvironmentText }); + lastAppliedEnvironmentRef.current = autoEnvironmentText.trim(); + clearPreparedDraft(); + setFlashMessage( + submission.issueNumber + ? `Posted issue #${submission.issueNumber}.` + : "Posted issue to GitHub.", + true, + 5000, + ); + onSubmitted(); + } else { + setFlashMessage( + submission.error ?? "GitHub posting failed. The reviewed draft was saved in My Submissions.", + false, + 7000, + ); + onSubmitted(); + } } catch (err: unknown) { const msg = - err instanceof Error ? err.message : "Failed to submit feedback"; - setFlash({ msg, ok: false }); - if (flashTimer.current) window.clearTimeout(flashTimer.current); - flashTimer.current = window.setTimeout(() => setFlash(null), 6000); + err instanceof Error ? err.message : "Failed to post feedback draft"; + setFlashMessage(msg, false, 7000); } finally { setSubmitting(false); } - }, [category, description, modelId, reasoningEffort, submitting, onSubmitted]); + }, [ + clearPreparedDraft, + draftBody, + draftLabelsText, + draftTitle, + autoEnvironmentText, + onSubmitted, + preparedDraft, + preparing, + setFlashMessage, + submitting, + ]); useEffect(() => { return () => { @@ -154,7 +445,13 @@ function NewReportTab({ }; }, []); - const submitDisabled = !description.trim() || !modelId || submitting; + const canPrepareDraft = form.summary.trim().length > 0 && !preparing && !submitting; + const canSubmitDraft = preparedDraft != null + && draftTitle.trim().length > 0 + && draftBody.trim().length > 0 + && !preparing + && !submitting; + const draftSource = formattingLabel(preparedDraft?.generationMode ?? null); if (!hasGithubToken) { return ( @@ -179,12 +476,14 @@ function NewReportTab({ return (
- {/* Category */}
Category
- {/* Description */}
- Description + Summary