From da3e09d38a8cf926392ca4d23fbf426ec2ab3b7f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:33:18 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20quick=20fixes=20=E2=80=94=20chat=20?= =?UTF-8?q?UX,=20feedback=20reporter,=20diagnostics,=20opencode=20runtime,?= =?UTF-8?q?=20MCP=20server=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broad set of incremental improvements across the desktop app: - Chat surface: subagent strip, task panels, work log, terminal drawer, git toolbar polish - Feedback reporter modal overhaul - Diagnostics dashboard enhancements - OpenCode inventory and runtime hardening with server manager - MCP server test and implementation updates - Model catalog and provider selector refinements - Claude cache TTL badge and utilities - Coordinator agent and orchestrator service updates Co-Authored-By: Claude Opus 4.6 (1M context) --- .ade/ade.yaml | 8 +- .ade/cto/identity.yaml | 19 +- apps/desktop/package-lock.json | 20 + apps/desktop/package.json | 1 + apps/desktop/src/main/main.ts | 33 +- .../services/chat/agentChatService.test.ts | 136 +- .../main/services/chat/agentChatService.ts | 267 ++- .../feedback/feedbackReporterService.test.ts | 181 ++ .../feedback/feedbackReporterService.ts | 280 ++- .../src/main/services/ipc/registerIpc.ts | 20 +- .../opencode/openCodeInventory.test.ts | 126 ++ .../services/opencode/openCodeInventory.ts | 258 ++- .../services/opencode/openCodeRuntime.test.ts | 441 +++++ .../main/services/opencode/openCodeRuntime.ts | 410 +++- .../opencode/openCodeServerManager.test.ts | 212 ++ .../opencode/openCodeServerManager.ts | 488 +++++ .../aiOrchestratorService.test.ts | 116 ++ .../orchestrator/aiOrchestratorService.ts | 129 +- .../orchestrator/coordinatorAgent.test.ts | 104 + .../services/orchestrator/coordinatorAgent.ts | 78 +- .../providerOrchestratorAdapter.ts | 1 + .../services/runtime/adeMcpLaunch.test.ts | 29 + .../src/main/services/runtime/adeMcpLaunch.ts | 7 +- apps/desktop/src/preload/global.d.ts | 2 + apps/desktop/src/preload/preload.ts | 3 + .../src/renderer/assets/AI Brain.lottie | Bin 0 -> 7740 bytes .../src/renderer/assets/brain-thinking.json | 1 + .../app/FeedbackReporterModal.test.tsx | 128 ++ .../components/app/FeedbackReporterModal.tsx | 244 ++- .../components/chat/AgentChatComposer.tsx | 45 +- .../components/chat/AgentChatMessageList.tsx | 563 +++--- .../chat/AgentChatPane.submit.test.tsx | 45 + .../components/chat/AgentChatPane.tsx | 165 +- .../components/chat/BottomDrawerSection.tsx | 63 + .../components/chat/ChatCommandMenu.tsx | 72 +- .../components/chat/ChatComposerShell.tsx | 12 +- .../components/chat/ChatGitToolbar.tsx | 87 +- .../components/chat/ChatProposedPlanCard.tsx | 32 +- .../components/chat/ChatSubagentStrip.tsx | 20 +- .../chat/ChatSubagentsPanel.test.tsx | 117 ++ .../components/chat/ChatSubagentsPanel.tsx | 358 ++++ .../components/chat/ChatSurfaceShell.tsx | 14 +- .../components/chat/ChatTasksPanel.tsx | 255 +++ .../components/chat/ChatTerminalDrawer.tsx | 71 +- .../components/chat/ChatTurnDivider.tsx | 25 +- .../components/chat/ChatWorkLogBlock.tsx | 221 ++- .../components/chat/chatExecutionSummary.ts | 64 +- .../components/chat/chatStatusVisuals.tsx | 19 +- .../components/chat/chatSurfaceTheme.ts | 9 +- .../components/chat/chatTranscriptRows.ts | 38 + .../missions/MissionThreadMessageList.tsx | 13 +- .../components/run/RunNetworkPanel.tsx | 4 +- .../DiagnosticsDashboardSection.test.tsx | 176 ++ .../settings/DiagnosticsDashboardSection.tsx | 203 +- .../components/shared/ClaudeCacheTtlBadge.tsx | 42 + .../components/shared/ModelCatalogPanel.tsx | 203 +- .../shared/ProviderModelSelector.tsx | 32 +- .../components/terminals/SessionCard.tsx | 12 + .../components/terminals/WorkStartSurface.tsx | 4 +- .../components/terminals/WorkViewArea.tsx | 11 + apps/desktop/src/renderer/index.css | 117 +- .../src/renderer/lib/claudeCacheTtl.test.ts | 49 + .../src/renderer/lib/claudeCacheTtl.ts | 41 + apps/desktop/src/renderer/lib/sessions.ts | 3 +- apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/chat.ts | 3 + apps/desktop/src/shared/types/config.ts | 35 + apps/desktop/src/shared/types/sessions.ts | 1 + apps/desktop/src/types/opencode-ai-sdk.d.ts | 20 + apps/mcp-server/src/mcpServer.test.ts | 1711 +++++++++-------- apps/mcp-server/src/mcpServer.ts | 192 +- 71 files changed, 6965 insertions(+), 1945 deletions(-) create mode 100644 apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts create mode 100644 apps/desktop/src/main/services/opencode/openCodeInventory.test.ts create mode 100644 apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts create mode 100644 apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts create mode 100644 apps/desktop/src/main/services/opencode/openCodeServerManager.ts create mode 100644 apps/desktop/src/renderer/assets/AI Brain.lottie create mode 100644 apps/desktop/src/renderer/assets/brain-thinking.json create mode 100644 apps/desktop/src/renderer/components/app/FeedbackReporterModal.test.tsx create mode 100644 apps/desktop/src/renderer/components/chat/BottomDrawerSection.tsx create mode 100644 apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx create mode 100644 apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx create mode 100644 apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx create mode 100644 apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.test.tsx create mode 100644 apps/desktop/src/renderer/components/shared/ClaudeCacheTtlBadge.tsx create mode 100644 apps/desktop/src/renderer/lib/claudeCacheTtl.test.ts create mode 100644 apps/desktop/src/renderer/lib/claudeCacheTtl.ts create mode 100644 apps/desktop/src/types/opencode-ai-sdk.d.ts diff --git a/.ade/ade.yaml b/.ade/ade.yaml index 27cdf3a30..3749bf06c 100644 --- a/.ade/ade.yaml +++ b/.ade/ade.yaml @@ -1,12 +1,10 @@ version: 1 processes: - id: hiwo8mbf - name: dogfood.sh local model fixes + name: dogfood.sh droid command: - - ./scripts/dogfood.sh - - local - - model - - fixes + - scripts/dogfood.sh + - droid-chat cwd: ./ stackButtons: [] testSuites: [] diff --git a/.ade/cto/identity.yaml b/.ade/cto/identity.yaml index e7a73393e..ad9417718 100644 --- a/.ade/cto/identity.yaml +++ b/.ade/cto/identity.yaml @@ -1,14 +1,7 @@ name: CTO -version: 1 -persona: >- - You are the CTO for this project inside ADE. - - You are the persistent technical lead who owns architecture, execution - quality, engineering continuity, and team direction. - - Use ADE's tools and project context to help the team move forward with clear, - concrete decisions. -personality: strategic +version: 3 +persona: Persistent project CTO with collaborative personality. +personality: casual modelPreferences: provider: claude model: sonnet @@ -28,4 +21,8 @@ openclawContextPolicy: - secret - token - system_prompt -updatedAt: 1970-01-01T00:00:00.000Z +onboardingState: + completedSteps: + - identity + completedAt: 2026-04-02T14:20:19.124Z +updatedAt: 2026-04-02T14:20:19.127Z diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index b75138530..fd9a55e65 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -37,6 +37,7 @@ "electron-updater": "^6.8.3", "framer-motion": "^12.34.2", "geist": "^1.7.0", + "lottie-react": "^2.4.1", "lucide-react": "^0.563.0", "monaco-editor": "^0.55.1", "motion": "^12.34.2", @@ -13652,6 +13653,25 @@ "loose-envify": "cli.js" } }, + "node_modules/lottie-react": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.1.tgz", + "integrity": "sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==", + "license": "MIT", + "dependencies": { + "lottie-web": "^5.10.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lottie-web": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", + "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==", + "license": "MIT" + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7d8bf71a2..17a955d7b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -66,6 +66,7 @@ "electron-updater": "^6.8.3", "framer-motion": "^12.34.2", "geist": "^1.7.0", + "lottie-react": "^2.4.1", "lucide-react": "^0.563.0", "monaco-editor": "^0.55.1", "motion": "^12.34.2", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 6a93a782a..684f85040 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3204,25 +3204,20 @@ app.whenReady().then(async () => { globalStatePath, }); - await createWindow(getActiveContext().logger); - - // Initial project context: load AFTER the window is visible so the main - // thread isn't blocked (DB load + service init) before anything renders. + // Dogfood and other explicit ADE_PROJECT_ROOT launches need the project + // context ready before the renderer boots, otherwise the window can paint + // the welcome state and swallow project selection into a confusing no-op. if (startupUserSelected) { - const startupRoot = normalizeProjectRoot(initialCandidate); - void (async () => { - try { - await switchProjectFromDialog(initialCandidate); - } catch { - if (!activeProjectRoot || activeProjectRoot === startupRoot) { - setActiveProject(null); - dormantContext = createDormantProjectContext(); - emitProjectChanged(null); - } - } - })(); + try { + await switchProjectFromDialog(initialCandidate); + } catch { + setActiveProject(null); + dormantContext = createDormantProjectContext(); + } } + await createWindow(getActiveContext().logger); + app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { await createWindow(getActiveContext().logger); @@ -3237,10 +3232,10 @@ app.whenReady().then(async () => { const current = getActiveContext(); const previousRoot = current.project?.rootPath; current.logger.info("app.before_quit"); - // Kill the shared OpenCode inventory server before quitting + // Kill any remaining OpenCode servers before quitting. try { - const { shutdownInventoryServer } = require("./services/opencode/openCodeInventory"); - shutdownInventoryServer(); + const { shutdownOpenCodeServers } = require("./services/opencode/openCodeServerManager"); + shutdownOpenCodeServers(); } catch { /* ignore if module not loaded */ } setActiveProject(null); dormantContext = createDormantProjectContext(previousRoot); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index f0eb5f481..d9ab6f946 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2,12 +2,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk"; -import { buildOpenCodePromptParts } from "../opencode/openCodeRuntime"; +import { buildOpenCodePromptParts, startOpenCodeSession } from "../opencode/openCodeRuntime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const generateText = vi.fn(); const streamText = vi.fn(); +vi.mock("@opencode-ai/sdk", () => ({ + createOpencodeServer: vi.fn(async () => ({ + url: "http://mock-opencode-server", + close: vi.fn(), + })), + createOpencodeClient: vi.fn(() => ({})), +})); + // --------------------------------------------------------------------------- // vi.hoisted mock state // --------------------------------------------------------------------------- @@ -303,6 +311,9 @@ vi.mock("../opencode/openCodeRuntime", () => ({ close: vi.fn(), }, close: vi.fn(), + touch: vi.fn(), + setBusy: vi.fn(), + setEvictionHandler: vi.fn(), client, }; }), @@ -1601,7 +1612,20 @@ describe("createAgentChatService", () => { const promptCalls = vi.mocked(buildOpenCodePromptParts).mock.calls; const firstUserContent = String(promptCalls[0]?.[0]?.prompt ?? ""); const secondUserContent = String(promptCalls[1]?.[0]?.prompt ?? ""); - + const resolvedTmpRoot = fs.realpathSync(tmpRoot); + const openCodeStartCalls = vi.mocked(startOpenCodeSession).mock.calls; + + expect(vi.mocked(resolveAdeMcpServerLaunch)).toHaveBeenCalledWith(expect.objectContaining({ + projectRoot: tmpRoot, + workspaceRoot: resolvedTmpRoot, + workspaceBinding: "project_root", + chatSessionId: session.id, + })); + expect(openCodeStartCalls.length).toBeGreaterThan(0); + expect(openCodeStartCalls[0]?.[0]).toEqual(expect.objectContaining({ + leaseKind: "shared", + dynamicMcpLaunch: expect.any(Object), + })); expect(firstUserContent).toContain("[ADE launch directive]"); expect(firstUserContent).toContain(tmpRoot); expect(firstUserContent).toContain("only inside that worktree"); @@ -3383,6 +3407,114 @@ describe("createAgentChatService", () => { expect(setPermissionMode.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[1]); }); + it("preserves Claude access overrides when entering and exiting plan mode", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let service: ReturnType["service"]; + let sessionId = ""; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-plan-preserve", + slash_commands: [], + }; + return; + } + + const sessionOpts = vi.mocked(unstable_v2_createSession).mock.calls.at(-1)?.[0] as any; + const enterResult = await sessionOpts.canUseTool("EnterPlanMode", {}, { + signal: new AbortController().signal, + toolUseID: "tool-enter-plan", + }); + expect(enterResult).toEqual({ behavior: "allow" }); + + const entered = await service.getSessionSummary(sessionId); + expect(entered?.permissionMode).toBe("plan"); + expect(entered?.claudePermissionMode).toBe("acceptEdits"); + + const exitPromise = sessionOpts.canUseTool("ExitPlanMode", { + planDescription: "Ship the approved Claude changes.", + }, { + signal: new AbortController().signal, + toolUseID: "tool-exit-plan", + }); + + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "approval_request" + && typeof ((event.event.detail as { request?: { kind?: string } } | undefined)?.request?.kind) === "string" + && ((event.event.detail as { request?: { kind?: string } } | undefined)?.request?.kind === "plan_approval"), + ); + + await service.approveToolUse({ + sessionId, + itemId: approvalEvent.event.itemId, + decision: "accept", + }); + + const exitResult = await exitPromise; + expect(exitResult).toMatchObject({ + behavior: "deny", + message: expect.stringContaining("exited plan mode"), + }); + + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Plan approved and preserved." }], + 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-plan-preserve", + setPermissionMode, + } as any); + + ({ service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + })); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + modelId: "anthropic/claude-sonnet-4-6", + permissionMode: "edit", + claudePermissionMode: "acceptEdits", + }); + sessionId = session.id; + + const result = await service.runSessionTurn({ + sessionId: session.id, + text: "Enter plan mode, then exit it after approval.", + }); + + expect(result.outputText).toContain("Plan approved and preserved."); + expect(setPermissionMode).toHaveBeenCalledWith("acceptEdits"); + + const summary = await service.getSessionSummary(session.id); + expect(summary?.permissionMode).toBe("edit"); + expect(summary?.claudePermissionMode).toBe("acceptEdits"); + }); + it("emits todo_update events for Claude TodoWrite tool uses", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index d9d2eeeda..4606a1338 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -78,6 +78,7 @@ import type { AgentChatHandoffArgs, AgentChatHandoffResult, AgentChatIdentityKey, + AgentChatNoticeDetail, AgentChatInteractionMode, AgentChatInterruptArgs, AgentChatModelInfo, @@ -251,6 +252,7 @@ type PersistedChatState = { lastLaneDirectiveKey?: string | null; manuallyNamed?: boolean; requestedCwd?: string | null; + idleSinceAt?: string | null; /** Persisted "Allow for Session" tool approval overrides (Claude runtime). */ approvalOverrides?: string[]; updatedAt: string; @@ -869,7 +871,7 @@ const DEFAULT_COLLABORATION_MODES_LIST_TIMEOUT_MS = 1_500; // positives during long-running tool calls (Agent, Bash, etc.) where no // stream events are emitted while the SDK waits for tool results. The user // can always interrupt manually if something is genuinely stuck. -const SESSION_INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +const SESSION_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const SESSION_CLEANUP_INTERVAL_MS = 60 * 1000; // check every 60 seconds const MAX_CONCURRENT_ACTIVE_RUNTIMES = 5; const MAX_SESSION_MAP_ENTRIES = 200; @@ -1784,6 +1786,19 @@ function resolveSessionClaudeAccessMode( ?? "default"; } +function legacyClaudeAccessModeToPermissionMode( + mode: AgentChatClaudeAccessMode, +): AgentChatSession["permissionMode"] { + switch (mode) { + case "acceptEdits": + return "edit"; + case "bypassPermissions": + return "full-auto"; + default: + return "default"; + } +} + function legacyPermissionModeToCodexApprovalPolicy( mode: AgentChatSession["permissionMode"] | undefined, ): AgentChatCodexApprovalPolicy | undefined { @@ -1878,6 +1893,36 @@ function applyLegacyPermissionModeToNativeControls( session.opencodePermissionMode = legacyPermissionModeToOpenCodePermissionMode(mode); } +type ClaudePlanModeTransition = "entered_plan_mode" | "exited_plan_mode"; + +type ClaudePlanModeNoticeDetail = AgentChatNoticeDetail & { + permissionModeTransition: ClaudePlanModeTransition; +}; + +function buildClaudePlanModeNoticeDetail(transition: ClaudePlanModeTransition): ClaudePlanModeNoticeDetail { + return { + title: transition === "entered_plan_mode" ? "Plan mode entered" : "Plan mode exited", + summary: transition === "entered_plan_mode" + ? "Claude switched into plan mode for this turn." + : "Claude left plan mode and resumed its prior access mode.", + permissionModeTransition: transition, + }; +} + +function applyClaudePlanModeTransition( + session: Pick, + nextInteractionMode: AgentChatInteractionMode, +): void { + session.interactionMode = nextInteractionMode; + if (nextInteractionMode === "plan") { + session.permissionMode = "plan"; + return; + } + session.permissionMode = legacyClaudeAccessModeToPermissionMode( + resolveSessionClaudeAccessMode(session, "default"), + ); +} + function hydrateNativePermissionControls( session: Pick< AgentChatSession, @@ -2935,10 +2980,44 @@ export function createAgentChatService(args: { runtime: ClaudeRuntime, managed: ManagedChatSession, ): ClaudeSDKOptions["canUseTool"] => async (toolName, input, sdkOptions): Promise => { + // ── EnterPlanMode interception ── + // Sync ADE session state when the SDK enters plan mode mid-session so + // the permission-mode picker in the UI stays in sync. + if (toolName === "EnterPlanMode") { + if (managed.session.permissionMode !== "plan") { + applyClaudePlanModeTransition(managed.session, "plan"); + persistChatState(managed); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Session entered plan mode", + detail: buildClaudePlanModeNoticeDetail("entered_plan_mode"), + turnId: runtime.activeTurnId ?? undefined, + }); + } + return { behavior: "allow" }; + } + // ── ExitPlanMode interception ── // Intercept ExitPlanMode to show a plan approval UI instead of letting the // SDK handle it natively (which just collapses into the work log). if (toolName === "ExitPlanMode") { + // Idempotency guard: if plan mode was already exited (e.g. retry after + // the first approval), return immediately without showing the approval UI + // again. This prevents the retry loop caused by the SDK's ExitPlanMode + // handler failing with ZodError. + const alreadyExited = managed.session.permissionMode !== "plan" + && managed.session.interactionMode !== "plan"; + if (alreadyExited) { + if (sdkOptions?.toolUseID) { + runtime.resolvedToolUseIds.add(String(sdkOptions.toolUseID)); + } + return { + behavior: "deny", + message: "Plan mode has already been exited. Proceed with implementation.", + }; + } + // In bypass / full-auto mode, auto-approve the plan without showing // approval UI — the user opted out of all permission gates. const effectiveAccess = managed.session.claudePermissionMode ?? managed.session.permissionMode; @@ -2946,8 +3025,7 @@ export function createAgentChatService(args: { // Transition out of plan mode so the UI reflects the change, // matching the state update performed after manual approval. if (managed.session.permissionMode === "plan" || managed.session.interactionMode === "plan") { - managed.session.permissionMode = "edit"; - applyLegacyPermissionModeToNativeControls(managed.session, "edit"); + applyClaudePlanModeTransition(managed.session, "default"); persistChatState(managed); } return { behavior: "allow" }; @@ -3024,13 +3102,34 @@ export function createAgentChatService(args: { if (approved) { // Switch session out of plan mode so the UI reflects the transition. if (managed.session.permissionMode === "plan" || managed.session.interactionMode === "plan") { - managed.session.permissionMode = "edit"; - applyLegacyPermissionModeToNativeControls(managed.session, "edit"); + applyClaudePlanModeTransition(managed.session, "default"); persistChatState(managed); } - // Allow the tool — the SDK will process ExitPlanMode normally and - // Claude will receive the standard "plan approved" tool result. - return { behavior: "allow" }; + + // Sync the SDK session so it knows plan mode ended. + try { + const sessionControl = getClaudeV2SessionControl(runtime.v2Session); + if (typeof sessionControl.setPermissionMode === "function") { + await sessionControl.setPermissionMode(resolveSessionClaudePermissionMode(managed.session, "default")); + } + } catch { /* best-effort — the deny result below handles the transition semantically */ } + + // Emit permission mode change notice for UI sync. + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Session exited plan mode", + detail: buildClaudePlanModeNoticeDetail("exited_plan_mode"), + turnId: runtime.activeTurnId ?? undefined, + }); + + // Return deny to bypass the SDK's built-in ExitPlanMode handler + // entirely (which can fail with ZodError due to schema mismatch). + // Claude sees the message text and knows the plan was approved. + return { + behavior: "deny", + message: "Plan approved by the user. The session has exited plan mode. Proceed with implementing the plan. Do not call ExitPlanMode again.", + }; } // Denied — tell Claude the user rejected the plan. @@ -4371,10 +4470,10 @@ export function createAgentChatService(args: { const mcpLaunch = resolveAdeMcpServerLaunch({ projectRoot, workspaceRoot: managed.laneWorktreePath, + workspaceBinding: "project_root", runtimeRoot, chatSessionId: managed.session.id, defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent", - ownerId: resolveWorkerIdentityAgentId(managed.session.identityKey) ?? undefined, computerUsePolicy: managed.session.computerUse, }); // Discover loaded local models so OpenCode's provider config includes them. @@ -4404,8 +4503,13 @@ export function createAgentChatService(args: { title: sessionService.get(managed.session.id)?.title ?? defaultChatSessionTitle("opencode"), sessionId: persisted?.providerSessionId, projectConfig: configSnapshot.effective, - mcpLaunch: isLightweightSession(managed.session) ? undefined : mcpLaunch, + dynamicMcpLaunch: isLightweightSession(managed.session) ? undefined : mcpLaunch, discoveredLocalModels, + ownerKind: "chat", + ownerId: managed.session.id, + ownerKey: `chat:${managed.session.id}`, + leaseKind: "shared", + logger, }); const runtime: OpenCodeRuntime = { @@ -4423,6 +4527,13 @@ export function createAgentChatService(args: { reasoningByPartId: new Map(), toolStateByPartId: new Map(), }; + handle.setEvictionHandler((reason) => { + if (managed.runtime?.kind === "opencode" && managed.runtime.handle === handle) { + teardownRuntime(managed, reason === "error" || reason === "config_changed" ? "handle_close" : reason); + } + }); + handle.setBusy(false); + handle.touch(); // Evict least-recent runtime if at capacity { @@ -4669,7 +4780,7 @@ export function createAgentChatService(args: { || managed.runtime?.kind === "opencode" || managed.runtime?.kind === "cursor") ) { - teardownRuntime(managed); + teardownRuntime(managed, "project_close"); refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); } return launchContext; @@ -4761,6 +4872,7 @@ export function createAgentChatService(args: { ...(managed.session.requestedCwd != null && String(managed.session.requestedCwd).trim().length ? { requestedCwd: String(managed.session.requestedCwd).trim() } : {}), + ...(managed.session.idleSinceAt !== undefined ? { idleSinceAt: managed.session.idleSinceAt ?? null } : {}), updatedAt: nowIso() }; @@ -4896,6 +5008,11 @@ export function createAgentChatService(args: { ...(typeof record.requestedCwd === "string" && record.requestedCwd.trim().length ? { requestedCwd: record.requestedCwd.trim() } : {}), + ...(typeof record.idleSinceAt === "string" + ? { idleSinceAt: record.idleSinceAt.trim() || null } + : record.idleSinceAt === null + ? { idleSinceAt: null } + : {}), updatedAt: typeof record.updatedAt === "string" && record.updatedAt.trim().length ? record.updatedAt : nowIso() }; hydrateNativePermissionControls(hydrated as Parameters[0]); @@ -4955,6 +5072,30 @@ export function createAgentChatService(args: { sessionService.setLastOutputPreview(managed.session.id, next); }; + const setSessionActive = (managed: ManagedChatSession): void => { + managed.session.status = "active"; + managed.session.idleSinceAt = null; + }; + + const setSessionIdle = ( + managed: ManagedChatSession, + options?: { idleSinceAt?: string | null }, + ): void => { + managed.session.status = "idle"; + if (options && "idleSinceAt" in options) { + managed.session.idleSinceAt = options.idleSinceAt ?? null; + } + }; + + const markSessionIdleWithFreshCache = (managed: ManagedChatSession): void => { + setSessionIdle(managed, { idleSinceAt: nowIso() }); + }; + + const setSessionEnded = (managed: ManagedChatSession): void => { + managed.session.status = "ended"; + managed.session.idleSinceAt = null; + }; + const clipText = (value: string, maxChars: number): string => { const trimmed = value.trim(); if (trimmed.length <= maxChars) return trimmed; @@ -5468,8 +5609,19 @@ export function createAgentChatService(args: { return resolvedLaneId; }; + const setOpenCodeRuntimeBusy = (runtime: OpenCodeRuntime, busy: boolean): void => { + runtime.busy = busy; + runtime.handle.setBusy(busy); + if (!busy) { + runtime.handle.touch(); + } + }; + /** Tear down the active runtime, releasing all resources and cancelling pending approvals. */ - const teardownRuntime = (managed: ManagedChatSession): void => { + const teardownRuntime = ( + managed: ManagedChatSession, + openCodeReason: "handle_close" | "idle_ttl" | "ended_session" | "model_switch" | "project_close" | "budget_eviction" | "paused_run" | "shutdown" = "handle_close", + ): void => { flushBufferedReasoning(managed); flushBufferedText(managed); if (managed.runtime?.kind === "codex") { @@ -5504,6 +5656,7 @@ export function createAgentChatService(args: { // Mark interrupted so the streaming catch block takes the graceful path managed.runtime.interrupted = true; managed.runtime.eventAbortController?.abort(); + managed.runtime.handle.setBusy(false); for (const pending of managed.runtime.pendingApprovals.values()) { managed.runtime.handle.client.postSessionIdPermissionsPermissionId({ path: { id: managed.runtime.handle.sessionId, permissionID: pending.permissionId }, @@ -5512,7 +5665,8 @@ export function createAgentChatService(args: { }).catch(() => {}); } managed.runtime.pendingApprovals.clear(); - try { managed.runtime.handle.close(); } catch { /* ignore */ } + managed.runtime.handle.setEvictionHandler(null); + try { managed.runtime.handle.close(openCodeReason); } catch { /* ignore */ } managed.runtime = null; } if (managed.runtime?.kind === "cursor") { @@ -5573,8 +5727,8 @@ export function createAgentChatService(args: { }); } - managed.session.status = "idle"; - teardownRuntime(managed); + setSessionIdle(managed); + teardownRuntime(managed, "handle_close"); managed.closed = false; managed.endedNotified = false; sessionService.reopen(managed.session.id); @@ -5752,12 +5906,12 @@ export function createAgentChatService(args: { sessionService.setHeadShaEnd(managed.session.id, endSha); } - managed.session.status = "ended"; + setSessionEnded(managed); managed.closed = true; managed.ctoSessionStartedAt = null; persistChatState(managed); - teardownRuntime(managed); + teardownRuntime(managed, "ended_session"); try { onSessionEnded?.({ laneId: managed.session.laneId, sessionId: managed.session.id, exitCode: options?.exitCode ?? null }); @@ -5818,6 +5972,7 @@ export function createAgentChatService(args: { computerUse: normalizePersistedComputerUse(persisted?.computerUse), completion: persisted?.completion ?? null, status: mapTerminalStatusToChatStatus(row.status), + idleSinceAt: persisted?.idleSinceAt ?? null, ...(persisted?.threadId ? { threadId: persisted.threadId } : {}), ...(persisted?.requestedCwd != null && String(persisted.requestedCwd).trim().length ? { requestedCwd: String(persisted.requestedCwd).trim() } @@ -5938,7 +6093,7 @@ export function createAgentChatService(args: { _rootPath: managed.laneWorktreePath, })); const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; - managed.session.status = "active"; + setSessionActive(managed); emitPreparedUserMessage(managed, { text: displayText, attachments, @@ -6170,7 +6325,7 @@ export function createAgentChatService(args: { runtime.activeTurnId = turnId; runtime.interrupted = false; runtime.resolvedToolUseIds.clear(); - managed.session.status = "active"; + setSessionActive(managed); const attachments = args.attachments ?? []; const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ @@ -6983,7 +7138,7 @@ export function createAgentChatService(args: { runtime.busy = false; runtime.activeTurnId = null; runtime.turnMemoryPolicyState = null; - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); reportProviderRuntimeReady("claude"); // Flush deferred session reset from mid-turn reasoning effort change @@ -7050,7 +7205,7 @@ export function createAgentChatService(args: { void emitTurnDiffSummaryIfChanged(managed, turnId); if (runtime.interrupted) { - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); emitChatEvent(managed, { type: "done", @@ -7059,7 +7214,7 @@ export function createAgentChatService(args: { ...doneModel, }); } else if (timeoutError) { - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); const errorMessage = effectiveError instanceof Error ? effectiveError.message : String(effectiveError); reportProviderRuntimeFailure("claude", errorMessage); emitChatEvent(managed, { @@ -7082,7 +7237,7 @@ export function createAgentChatService(args: { } else if (isAbortRelatedError(effectiveError)) { // System-triggered abort (dispose/teardown) that wasn't flagged as interrupted. // Treat as interruption to avoid surfacing raw SDK messages like "aborted by user". - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); emitChatEvent(managed, { type: "done", @@ -7091,7 +7246,7 @@ export function createAgentChatService(args: { ...doneModel, }); } else { - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); const isAuthFailure = isClaudeRuntimeAuthError(effectiveError); const errorMessage = isAuthFailure ? CLAUDE_RUNTIME_AUTH_ERROR @@ -7173,10 +7328,10 @@ export function createAgentChatService(args: { throw new Error(validation.reason); } const turnId = randomUUID(); - runtime.busy = true; + setOpenCodeRuntimeBusy(runtime, true); runtime.activeTurnId = turnId; runtime.interrupted = false; - managed.session.status = "active"; + setSessionActive(managed); const attachments = args.attachments ?? []; const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ ...attachment, @@ -7275,6 +7430,7 @@ export function createAgentChatService(args: { body: { agent: mapPermissionModeToOpenCodeAgent(runtime.permissionMode), model: resolveOpenCodeModelSelection(runtime.modelDescriptor), + ...(runtime.handle.toolSelection ? { tools: runtime.handle.toolSelection } : {}), parts: buildOpenCodePromptParts({ prompt: userContent, files: toPromptFiles, @@ -7563,7 +7719,7 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "todo_update", items: event.properties.todos - .map((todo) => ({ + .map((todo: { id: string; content: string; status: string }) => ({ id: todo.id, description: todo.content, status: todo.status === "completed" @@ -7600,10 +7756,10 @@ export function createAgentChatService(args: { persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); void emitTurnDiffSummaryIfChanged(managed, turnId); if (runtime.interrupted) { - runtime.busy = false; + setOpenCodeRuntimeBusy(runtime, false); runtime.activeTurnId = null; runtime.eventAbortController = null; - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); emitChatEvent(managed, { type: "done", @@ -7614,10 +7770,10 @@ export function createAgentChatService(args: { }); persistChatState(managed); } else { - runtime.busy = false; + setOpenCodeRuntimeBusy(runtime, false); runtime.activeTurnId = null; runtime.eventAbortController = null; - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); emitChatEvent(managed, { type: "status", turnStatus: "completed", turnId }); emitChatEvent(managed, { @@ -7649,13 +7805,13 @@ export function createAgentChatService(args: { } } } catch (error) { - runtime.busy = false; + setOpenCodeRuntimeBusy(runtime, false); runtime.activeTurnId = null; runtime.eventAbortController = null; void emitTurnDiffSummaryIfChanged(managed, turnId); if (runtime.interrupted) { - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); emitChatEvent(managed, { type: "done", @@ -7666,7 +7822,7 @@ export function createAgentChatService(args: { }); } else if (isAbortRelatedError(error)) { // System-triggered abort (dispose/teardown) that wasn't flagged as interrupted. - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); emitChatEvent(managed, { type: "done", @@ -7676,7 +7832,7 @@ export function createAgentChatService(args: { ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), }); } else { - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); const { message: errorMessage, errorInfo } = classifyOpenCodeError( error, @@ -8547,7 +8703,7 @@ export function createAgentChatService(args: { runtime.agentMessageScopeByTurn.clear(); runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); - managed.session.status = "active"; + setSessionActive(managed); if (!turnId || runtime.startedTurnId !== turnId) { runtime.startedTurnId = turnId; emitChatEvent(managed, { @@ -8591,7 +8747,7 @@ export function createAgentChatService(args: { runtime.recentNotificationKeys.clear(); const status = mapCodexTurnStatus(turn?.status); const usage = normalizeUsagePayload(turn?.usage ?? turn?.totalUsage); - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); runtime.approvals.clear(); if (status === "failed" && turn?.error?.message) { @@ -8817,7 +8973,7 @@ export function createAgentChatService(args: { runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); runtime.approvals.clear(); - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); stopActiveCodexSubagents(managed, runtime, turnId, "Interrupted by user"); emitChatEvent(managed, { type: "status", @@ -10002,6 +10158,7 @@ export function createAgentChatService(args: { model: DEFAULT_CODEX_MODEL, capabilityMode: "full_mcp", status: "idle", + idleSinceAt: null, createdAt: nowIso(), lastActivityAt: nowIso() }, @@ -10353,6 +10510,7 @@ export function createAgentChatService(args: { computerUse: computerUsePolicy, completion: null, status: "idle", + idleSinceAt: null, createdAt: startedAt, lastActivityAt: startedAt, ...(typeof requestedCwd === "string" && requestedCwd.trim().length @@ -10587,7 +10745,7 @@ export function createAgentChatService(args: { if (managed.session.status === "ended") { sessionService.reopen(sessionId); - managed.session.status = "idle"; + setSessionIdle(managed); managed.closed = false; managed.endedNotified = false; managed.ctoSessionStartedAt = managed.session.identityKey === "cto" ? nowIso() : null; @@ -10663,7 +10821,7 @@ export function createAgentChatService(args: { || normalizedMsg.includes("busy"); if (!isBusyError) { - managed.session.status = "idle"; + setSessionIdle(managed); } if (managed.runtime?.kind === "codex" && !isBusyError) { @@ -10672,7 +10830,7 @@ export function createAgentChatService(args: { managed.runtime.itemTurnIdByItemId.clear(); } if (managed.runtime?.kind === "opencode" && !isBusyError) { - managed.runtime.busy = false; + setOpenCodeRuntimeBusy(managed.runtime, false); managed.runtime.activeTurnId = null; managed.runtime.eventAbortController = null; } @@ -11270,7 +11428,7 @@ export function createAgentChatService(args: { return existing; } } else if (managed.runtime) { - teardownRuntime(managed); + teardownRuntime(managed, "handle_close"); } // Evict least-recent runtime if at capacity @@ -11372,7 +11530,7 @@ export function createAgentChatService(args: { runtime.interrupted = false; runtime.busy = true; runtime.activeTurnId = turnId; - managed.session.status = "active"; + setSessionActive(managed); const displayText = args.displayText.trim().length ? args.displayText.trim() : args.promptText; if (!args.optimisticCursorTurnStart) { @@ -11493,7 +11651,7 @@ export function createAgentChatService(args: { void emitTurnDiffSummaryIfChanged(managed, turnId); if (runtime.interrupted || promptRes.stopReason === "cancelled") { - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); cancelQueuedSteers(managed, runtime, "interrupted"); emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); for (const ev of mapStopReasonToTerminalEvents({ @@ -11506,7 +11664,7 @@ export function createAgentChatService(args: { emitChatEvent(managed, ev); } } else { - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); emitChatEvent(managed, { type: "status", turnStatus: "completed", turnId }); for (const ev of mapStopReasonToTerminalEvents({ stopReason: promptRes.stopReason, @@ -11530,7 +11688,7 @@ export function createAgentChatService(args: { }); persistChatState(managed); } catch (error) { - managed.session.status = "idle"; + markSessionIdleWithFreshCache(managed); const msg = error instanceof Error ? error.message : String(error); // Drain pending permission waiters so they don't block future sends. @@ -11573,7 +11731,7 @@ export function createAgentChatService(args: { runtime.busy = false; runtime.activeTurnId = null; if (managed.session.status === "active") { - managed.session.status = "idle"; + setSessionIdle(managed); } } if (!managed.closed && shouldDeliverQueuedSteer) { @@ -11803,7 +11961,7 @@ export function createAgentChatService(args: { }); emitChatEvent(prepared.managed, { type: "status", turnStatus: "started", turnId }); captureTurnBeforeSha(prepared.managed); - prepared.managed.session.status = "active"; + setSessionActive(prepared.managed); persistChatState(prepared.managed); // NOTE: onDispatched is NOT called here. It will be called inside // runCursorTurn after the real ACP prompt has been initiated, so the @@ -12244,7 +12402,7 @@ export function createAgentChatService(args: { } sessionService.reopen(sessionId); - managed.session.status = "idle"; + setSessionIdle(managed); managed.closed = false; managed.endedNotified = false; managed.ctoSessionStartedAt = managed.session.identityKey === "cto" ? nowIso() : null; @@ -12316,6 +12474,9 @@ export function createAgentChatService(args: { computerUse: liveSession?.computerUse ?? normalizePersistedComputerUse(persisted?.computerUse), completion: liveSession?.completion ?? persisted?.completion ?? null, status: liveSession?.status ?? (row.status === "running" ? "idle" : "ended"), + idleSinceAt: (liveSession?.status ?? (row.status === "running" ? "idle" : "ended")) === "idle" + ? liveSession?.idleSinceAt ?? persisted?.idleSinceAt ?? null + : null, startedAt: row.startedAt, endedAt: row.endedAt, lastActivityAt: liveSession?.lastActivityAt ?? persisted?.updatedAt ?? row.endedAt ?? row.startedAt, @@ -12903,7 +13064,7 @@ export function createAgentChatService(args: { && !managed.closed && now - managed.lastActivityTimestamp > SESSION_INACTIVITY_TIMEOUT_MS ) { - teardownRuntime(managed); + teardownRuntime(managed, "idle_ttl"); } } }, SESSION_CLEANUP_INTERVAL_MS); @@ -12923,7 +13084,7 @@ export function createAgentChatService(args: { } } if (oldest) { - teardownRuntime(oldest); + teardownRuntime(oldest, "budget_eviction"); } }; @@ -12989,7 +13150,7 @@ export function createAgentChatService(args: { || managed.session.model !== nextModel; if (managed.runtime && modelChanged) { - teardownRuntime(managed); + teardownRuntime(managed, "model_switch"); refreshReconstructionContext(managed, { includeConversationTail: true }); } @@ -13187,7 +13348,7 @@ export function createAgentChatService(args: { } if (resetRuntimeForComputerUse && managed.runtime) { - teardownRuntime(managed); + teardownRuntime(managed, "model_switch"); refreshReconstructionContext(managed, { includeConversationTail: true }); } diff --git a/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts b/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts new file mode 100644 index 000000000..185e80419 --- /dev/null +++ b/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createFeedbackReporterService } from "./feedbackReporterService"; + +vi.mock("electron", () => ({ + BrowserWindow: { + getAllWindows: () => [], + }, +})); + +function createDb() { + const store = new Map(); + return { + getJson(key: string): T | null { + return (store.get(key) as T | undefined) ?? null; + }, + setJson(key: string, value: unknown) { + store.set(key, JSON.parse(JSON.stringify(value))); + }, + }; +} + +function createLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe("createFeedbackReporterService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("posts successfully when the model wraps JSON in prose", async () => { + const db = createDb(); + const logger = createLogger(); + const executeTask = vi.fn(async () => ({ + text: [ + "I reviewed the request. Here is the issue:", + "```json", + JSON.stringify({ + title: "Improve failed submission details in feedback reporter", + body: "## Description\n\nShow the saved error in My Submissions.", + labels: ["bug"], + }), + "```", + ].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, + }); + + service.submit({ + category: "bug", + userDescription: "The failed submission view should show the saved error.", + modelId: "anthropic/claude-opus-4-6", + }); + + 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()); + }); + + it("falls back to a deterministic issue draft when the model output is unusable", 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 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", + }); + + await vi.waitFor(() => { + expect(apiRequest).toHaveBeenCalledTimes(1); + }); + + 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", + }), + ); + }); + + it("stores a stage-specific error 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"); + }); + + 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: "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] = service.list(); + expect(submission?.generatedTitle).toBe("Improve feedback reporter determinism"); + expect(submission?.error).toBe("Posting failed: GitHub API unavailable"); + expect(logger.error).toHaveBeenCalledWith( + "feedback.failed", + expect.objectContaining({ + error: "Posting failed: GitHub API unavailable", + }), + ); + }); +}); diff --git a/apps/desktop/src/main/services/feedback/feedbackReporterService.ts b/apps/desktop/src/main/services/feedback/feedbackReporterService.ts index 421e02e97..8c9e7d775 100644 --- a/apps/desktop/src/main/services/feedback/feedbackReporterService.ts +++ b/apps/desktop/src/main/services/feedback/feedbackReporterService.ts @@ -5,6 +5,7 @@ import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; import type { createGithubService } from "../github/githubService"; +import { parseStructuredOutput } from "../ai/utils"; import type { FeedbackSubmission, FeedbackSubmitArgs, @@ -12,11 +13,179 @@ import type { } from "../../../shared/types/feedback"; const DB_KEY = "feedback:submissions"; +const ALLOWED_LABELS = new Set([ + "bug", "enhancement", "question", "documentation", + "good first issue", "help wanted", "invalid", "wontfix", +]); +const FEEDBACK_ISSUE_JSON_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string" }, + body: { type: "string" }, + labels: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["title", "body", "labels"], +} as const; + +type FeedbackIssueDraft = { + title: string; + body: string; + labels: string[]; +}; function nowIso(): string { return new Date().toISOString(); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function clampText(value: string, maxLength: number): string { + const trimmed = normalizeWhitespace(value); + if (trimmed.length <= maxLength) return trimmed; + return `${trimmed.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`; +} + +function titleCaseFirst(value: string): string { + if (!value) return value; + return value[0]!.toUpperCase() + value.slice(1); +} + +function defaultLabelsForCategory(category: FeedbackSubmission["category"]): string[] { + switch (category) { + case "bug": + return ["bug"]; + case "question": + return ["question"]; + case "feature": + case "enhancement": + return ["enhancement"]; + } +} + +function normalizeLabels( + category: FeedbackSubmission["category"], + labels: unknown, +): string[] { + const normalized = Array.isArray(labels) + ? labels + .map((value) => String(value ?? "").trim().toLowerCase()) + .filter((value) => ALLOWED_LABELS.has(value)) + : []; + const combined = [...normalized, ...defaultLabelsForCategory(category)]; + 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)); +} + +function fallbackBody(submission: FeedbackSubmission): string { + const description = submission.userDescription.trim(); + switch (submission.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"); + case "question": + return [ + "## Description", + "", + description, + "", + "## Context", + "", + "Not provided.", + "", + "## Expected Guidance", + "", + "Not provided.", + ].join("\n"); + case "feature": + case "enhancement": + return [ + "## Description", + "", + description, + "", + "## Use Case", + "", + "Not provided.", + "", + "## Proposed Solution", + "", + "Not provided.", + "", + "## Alternatives Considered", + "", + "Not provided.", + ].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); + + return { + draft: { + title, + body, + labels, + }, + usedFallback, + }; +} + function emitUpdate(submission: FeedbackSubmission): void { const event: FeedbackSubmissionEvent = { type: "feedback-submission-updated", @@ -99,69 +268,74 @@ export function createFeedbackReporterService({ save(submission); emitUpdate(submission); - 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), - cwd: projectRoot, - model: submission.modelId, - permissionMode: "read-only", - oneShot: true, - }); - - let parsed: { title: string; body: string; labels: string[] }; + let normalizedDraft: FeedbackIssueDraft; try { - const text = result.text.trim(); - // Strip markdown fences if present - const jsonText = text.startsWith("```") - ? text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "") - : text; - parsed = JSON.parse(jsonText); - const ALLOWED_LABELS = new Set([ - "bug", "enhancement", "question", "documentation", - "good first issue", "help wanted", "invalid", "wontfix", - ]); - parsed.labels = (parsed.labels ?? []).filter( - (label: string) => ALLOWED_LABELS.has(label), - ); - } catch { - throw new Error("Failed to parse AI response as JSON"); - } + 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), + cwd: projectRoot, + model: submission.modelId, + jsonSchema: FEEDBACK_ISSUE_JSON_SCHEMA, + permissionMode: "read-only", + oneShot: true, + }); + + 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, + }); + } - submission.generatedTitle = parsed.title; - submission.generatedBody = parsed.body; + submission.generatedTitle = normalized.draft.title; + submission.generatedBody = normalized.draft.body; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Generation failed: ${message}`); + } // -- Post to GitHub -- submission.status = "posting"; save(submission); emitUpdate(submission); - const { data } = await githubService.apiRequest<{ - html_url: string; - number: number; - }>({ - method: "POST", - path: "/repos/arul28/ADE/issues", - body: { - title: parsed.title, - body: parsed.body, - labels: parsed.labels, - }, - }); + 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); + 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, - }); + 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"; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index b05ccb49a..2df191e35 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -326,6 +326,7 @@ import type { AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, + OpenCodeRuntimeSnapshot, MemoryHealthScope, MemoryHealthStats, SyncDesktopConnectionDraft, @@ -786,9 +787,9 @@ async function enrichSessionsForLaneList( if (session.status !== "running") return session; const chat = chatSummaryBySessionId.get(session.id); if (!chat) return session; - if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const }; - if (chat.status === "active") return { ...session, runtimeState: "running" as const }; - if (chat.status === "idle") return { ...session, runtimeState: "idle" as const }; + if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; + if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; + if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; return session; }); } @@ -2141,6 +2142,11 @@ export function registerIpc({ } }); + ipcMain.handle(IPC.aiGetOpenCodeRuntimeDiagnostics, async (): Promise => { + const { getOpenCodeRuntimeSnapshot } = await import("../opencode/openCodeRuntime"); + return getOpenCodeRuntimeSnapshot(); + }); + ipcMain.handle(IPC.aiStoreApiKey, async (_event, arg: { provider: string; key: string }): Promise => { const { storeApiKey } = await import("../ai/apiKeyStore"); storeApiKey(arg.provider, arg.key); @@ -2804,7 +2810,7 @@ export function registerIpc({ ipcMain.handle(IPC.orchestratorResumeRun, async (_event, arg: ResumeOrchestratorRunArgs): Promise => { const ctx = getCtx(); - return ctx.orchestratorService.resumeRun(arg); + return ctx.aiOrchestratorService.resumeRun(arg); }); ipcMain.handle(IPC.orchestratorCancelRun, async (_event, arg: CancelOrchestratorRunArgs): Promise => { @@ -3902,9 +3908,9 @@ export function registerIpc({ if (session.status !== "running") return session; const chat = chatSummaryBySessionId.get(session.id); if (!chat) return session; - if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const }; - if (chat.status === "active") return { ...session, runtimeState: "running" as const }; - if (chat.status === "idle") return { ...session, runtimeState: "idle" as const }; + if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; + if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; + if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; return session; }); }, diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts new file mode 100644 index 000000000..0ac113c00 --- /dev/null +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => ({ + acquireSharedOpenCodeServer: vi.fn(async () => ({ + url: "http://127.0.0.1:4101", + release: vi.fn(), + close: vi.fn(), + touch: vi.fn(), + setBusy: vi.fn(), + setEvictionHandler: vi.fn(), + })), + shutdownOpenCodeServers: vi.fn(), + providerList: vi.fn(async () => ({ + data: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + tool_call: true, + reasoning: true, + limit: { context: 200000, output: 4000 }, + }, + }, + }, + ], + }, + })), +})); + +vi.mock("@opencode-ai/sdk", () => ({ + createOpencodeClient: vi.fn(() => ({ + provider: { + list: mockState.providerList, + }, + })), +})); + +vi.mock("./openCodeRuntime", () => ({ + resolveOpenCodeExecutablePath: vi.fn(() => "/Users/admin/.opencode/bin/opencode"), + buildOpenCodeMergedConfig: vi.fn(() => ({ + share: "disabled", + autoupdate: false, + snapshot: false, + provider: { openai: { options: { apiKey: "test" } } }, + })), + buildSharedOpenCodeServerKey: vi.fn(() => "shared:test-config"), +})); + +vi.mock("./openCodeServerManager", () => ({ + acquireSharedOpenCodeServer: mockState.acquireSharedOpenCodeServer, + shutdownOpenCodeServers: mockState.shutdownOpenCodeServers, +})); + +import { + clearOpenCodeInventoryCache, + probeOpenCodeProviderInventory, + shutdownInventoryServer, +} from "./openCodeInventory"; + +describe("openCodeInventory", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearOpenCodeInventoryCache(); + }); + + it("reuses the same shared OpenCode server key as live sessions", async () => { + const logger = { warn: vi.fn() } as any; + + await probeOpenCodeProviderInventory({ + projectRoot: "/repo", + projectConfig: { ai: {} }, + logger, + force: true, + }); + + expect(mockState.acquireSharedOpenCodeServer).toHaveBeenCalledWith(expect.objectContaining({ + key: "shared:test-config", + ownerKind: "inventory", + ownerId: "/repo", + })); + }); + + it("only clears cache when shutting down inventory state", () => { + shutdownInventoryServer(); + expect(mockState.shutdownOpenCodeServers).toHaveBeenCalledWith({ leaseKind: "shared", ownerKind: "inventory" }); + }); + + it("does not filter local providers when discovery data is absent", async () => { + const logger = { warn: vi.fn() } as any; + mockState.providerList.mockResolvedValueOnce({ + data: { + connected: ["ollama"], + all: [ + { + id: "ollama", + name: "Ollama", + models: { + "llama-3.1": { + id: "llama-3.1", + name: "Llama 3.1", + tool_call: true, + reasoning: true, + limit: { context: 128000, output: 4096 }, + }, + }, + }, + ], + }, + } as any); + + const result = await probeOpenCodeProviderInventory({ + projectRoot: "/repo", + projectConfig: { ai: { localProviders: { ollama: { enabled: true } } } }, + logger, + force: true, + }); + + expect(result.modelIds).toContain("opencode/ollama/llama-3.1"); + expect(result.descriptors).toHaveLength(1); + }); +}); diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.ts index 8e0b3ef91..9fd15b3c9 100644 --- a/apps/desktop/src/main/services/opencode/openCodeInventory.ts +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.ts @@ -7,12 +7,14 @@ import { replaceDynamicOpenCodeModelDescriptors, type ModelDescriptor, } from "../../../shared/modelRegistry"; +import { stableStringify } from "../shared/utils"; import { + buildSharedOpenCodeServerKey, buildOpenCodeMergedConfig, - createOpencodeServerWithRetry, resolveOpenCodeExecutablePath, type DiscoveredLocalModelEntry, } from "./openCodeRuntime"; +import { acquireSharedOpenCodeServer, shutdownOpenCodeServers } from "./openCodeServerManager"; const TTL_MS = 60_000; /** How long an idle inventory server stays alive before being killed. */ @@ -36,74 +38,16 @@ type CacheEntry = { }; let inventoryCache: CacheEntry | null = null; - -// ── Shared server with idle TTL (avoids spawning a new process per probe) ── - -type SharedServer = { - url: string; - close(): void; - configFingerprint: string; - idleTimer: ReturnType | null; -}; - -let sharedServer: SharedServer | null = null; const probeInFlightMap = new Map>(); -function forceKillServer(server: { close(): void }): void { - try { - server.close(); - } catch { - // ignore - } -} - -function resetIdleTimer(): void { - if (!sharedServer) return; - if (sharedServer.idleTimer) clearTimeout(sharedServer.idleTimer); - sharedServer.idleTimer = setTimeout(() => { - if (sharedServer) { - forceKillServer(sharedServer); - sharedServer = null; - } - }, SERVER_IDLE_TTL_MS); -} - -async function getOrCreateServer( - config: ReturnType, - fp: string, -): Promise<{ url: string }> { - // Reuse existing server if config hasn't changed - if (sharedServer && sharedServer.configFingerprint === fp) { - resetIdleTimer(); - return { url: sharedServer.url }; - } - // Config changed — kill old server - if (sharedServer) { - forceKillServer(sharedServer); - sharedServer = null; - } - const result = await createOpencodeServerWithRetry(config); - sharedServer = { - url: result.server.url, - close: result.server.close, - configFingerprint: fp, - idleTimer: null, - }; - resetIdleTimer(); - return { url: sharedServer.url }; -} - export function clearOpenCodeInventoryCache(): void { inventoryCache = null; } -/** Shut down the shared inventory server immediately (e.g. on app quit). */ +/** Shut down the shared inventory server immediately and clear the cached probe state (e.g. on app quit). */ export function shutdownInventoryServer(): void { - if (sharedServer) { - if (sharedServer.idleTimer) clearTimeout(sharedServer.idleTimer); - forceKillServer(sharedServer); - sharedServer = null; - } + shutdownOpenCodeServers({ leaseKind: "shared", ownerKind: "inventory" }); + clearOpenCodeInventoryCache(); } function fingerprintOpenCodeConfig( @@ -111,7 +55,7 @@ function fingerprintOpenCodeConfig( discoveredLocalModels?: DiscoveredLocalModelEntry[], ): string { const ai = projectConfig.ai ?? {}; - return JSON.stringify({ + return stableStringify({ apiKeys: ai.apiKeys ?? {}, localProviders: ai.localProviders ?? {}, discoveredModels: discoveredLocalModels?.map((m) => `${m.provider}/${m.modelId}`).sort() ?? [], @@ -172,99 +116,125 @@ export async function probeOpenCodeProviderInventory(args: { projectConfig: args.projectConfig, discoveredLocalModels: args.discoveredLocalModels, }); - const { url } = await getOrCreateServer(config, fp); + const lease = await acquireSharedOpenCodeServer({ + config, + key: buildSharedOpenCodeServerKey(config), + ownerKind: "inventory", + ownerId: args.projectRoot, + idleTtlMs: SERVER_IDLE_TTL_MS, + logger: args.logger, + }); const client = createOpencodeClient({ - baseUrl: url, + baseUrl: lease.url, directory: args.projectRoot, }); - const listed = await client.provider.list({ - query: { directory: args.projectRoot }, - }); - const data = listed.data; - if (!data) { - throw new Error("OpenCode provider.list returned no data."); - } - const connected = new Set(data.connected); - const descriptors: ModelDescriptor[] = []; - const providerInfos: OpenCodeProviderInfo[] = data.all.map((p) => ({ - id: p.id, - name: typeof p.name === "string" ? p.name : p.id, - connected: connected.has(p.id), - modelCount: Object.keys(p.models ?? {}).length, - })); + try { + const listed = await client.provider.list({ + query: { directory: args.projectRoot }, + }); + const data = listed.data as + | { + connected: string[]; + all: Array<{ + id: string; + name?: string; + models?: Record>; + }>; + } + | undefined; + if (!data) { + throw new Error("OpenCode provider.list returned no data."); + } + const connected = new Set(data.connected); + const descriptors: ModelDescriptor[] = []; + const providerInfos: OpenCodeProviderInfo[] = data.all.map((p: { + id: string; + name?: string; + models?: Record>; + }) => ({ + id: p.id, + name: typeof p.name === "string" ? p.name : p.id, + connected: connected.has(p.id), + modelCount: Object.keys(p.models ?? {}).length, + })); - // Build a set of loaded local model IDs so we can filter out unloaded models - // that OpenCode discovers independently from the local provider endpoints. - const loadedLocalModelIds = new Map>(); - if (args.discoveredLocalModels) { - for (const entry of args.discoveredLocalModels) { - if (entry.loaded === false) continue; - let set = loadedLocalModelIds.get(entry.provider); - if (!set) { - set = new Set(); - loadedLocalModelIds.set(entry.provider, set); + // Build a set of loaded local model IDs so we can filter out unloaded models + // that OpenCode discovers independently from the local provider endpoints. + const loadedLocalModelIds = new Map>(); + const discoveredLocalProviderIds = new Set(); + if (args.discoveredLocalModels) { + for (const entry of args.discoveredLocalModels) { + discoveredLocalProviderIds.add(entry.provider); + if (entry.loaded === false) continue; + let set = loadedLocalModelIds.get(entry.provider); + if (!set) { + set = new Set(); + loadedLocalModelIds.set(entry.provider, set); + } + set.add(entry.modelId); } - set.add(entry.modelId); } - } - for (const provider of data.all) { - if (!connected.has(provider.id)) continue; - const isLocal = isLocalProviderFamily(provider.id); - const allowedModels = isLocal ? loadedLocalModelIds.get(provider.id) : undefined; - const models = provider.models ?? {}; - for (const model of Object.values(models)) { - const mid = typeof model.id === "string" ? model.id.trim() : ""; - if (!mid.length) continue; - // For local providers, only include models that are actively loaded. - if (isLocal && (!allowedModels || !allowedModels.has(mid))) continue; - const raw = model as Record; - const variantKeys = extractVariantKeys(raw); - const displayName = typeof model.name === "string" && model.name.trim().length ? model.name.trim() : undefined; - const ctx = typeof model.limit === "object" && model.limit && "context" in model.limit - ? Number((model.limit as { context?: number }).context) - : undefined; - const out = typeof model.limit === "object" && model.limit && "output" in model.limit - ? Number((model.limit as { output?: number }).output) - : undefined; - descriptors.push( - createDynamicOpenCodeModelDescriptor("", { - openCodeProviderId: provider.id, - openCodeModelId: mid, - ...(displayName ? { displayName } : {}), - ...(Number.isFinite(ctx) && (ctx as number) > 0 ? { contextWindow: ctx as number } : {}), - ...(Number.isFinite(out) && (out as number) > 0 ? { maxOutputTokens: out as number } : {}), - ...(variantKeys.length ? { reasoningTiers: variantKeys } : {}), - capabilities: { - tools: model.tool_call !== false, - vision: Boolean(model.modalities?.input?.includes("image")), - reasoning: model.reasoning !== false, - streaming: true, - }, - }), - ); + for (const provider of data.all) { + if (!connected.has(provider.id)) continue; + const isLocal = isLocalProviderFamily(provider.id); + const discoveryExists = isLocal && discoveredLocalProviderIds.has(provider.id); + const allowedModels = discoveryExists ? loadedLocalModelIds.get(provider.id) : undefined; + const models = provider.models ?? {}; + for (const model of Object.values(models)) { + const modelRecord = model as Record; + const mid = typeof modelRecord.id === "string" ? modelRecord.id.trim() : ""; + if (!mid.length) continue; + // For local providers, only include models that are actively loaded. + if (discoveryExists && (!allowedModels || !allowedModels.has(mid))) continue; + const variantKeys = extractVariantKeys(modelRecord); + const displayName = typeof modelRecord.name === "string" && modelRecord.name.trim().length ? modelRecord.name.trim() : undefined; + const limit = typeof modelRecord.limit === "object" && modelRecord.limit + ? modelRecord.limit as { context?: number; output?: number } + : null; + const ctx = typeof limit?.context === "number" + ? Number(limit.context) + : undefined; + const out = typeof limit?.output === "number" + ? Number(limit.output) + : undefined; + const modalities = modelRecord.modalities as { input?: string[] } | undefined; + descriptors.push( + createDynamicOpenCodeModelDescriptor("", { + openCodeProviderId: provider.id, + openCodeModelId: mid, + ...(displayName ? { displayName } : {}), + ...(Number.isFinite(ctx) && (ctx as number) > 0 ? { contextWindow: ctx as number } : {}), + ...(Number.isFinite(out) && (out as number) > 0 ? { maxOutputTokens: out as number } : {}), + ...(variantKeys.length ? { reasoningTiers: variantKeys } : {}), + capabilities: { + tools: modelRecord.tool_call !== false, + vision: Boolean(modalities?.input?.includes("image")), + reasoning: modelRecord.reasoning !== false, + streaming: true, + }, + }), + ); + } } - } - replaceDynamicOpenCodeModelDescriptors(descriptors); - const modelIds = [...descriptors.map((d) => d.id)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); - inventoryCache = { - cachedAt: Date.now(), - projectRoot: args.projectRoot, - configFingerprint: fp, - modelIds, - providers: providerInfos, - error: null, - }; - return { modelIds, providers: providerInfos, error: null, descriptors }; + replaceDynamicOpenCodeModelDescriptors(descriptors); + const modelIds = [...descriptors.map((d) => d.id)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + inventoryCache = { + cachedAt: Date.now(), + projectRoot: args.projectRoot, + configFingerprint: fp, + modelIds, + providers: providerInfos, + error: null, + }; + return { modelIds, providers: providerInfos, error: null, descriptors }; + } finally { + lease.release("handle_close"); + } } catch (err) { const message = err instanceof Error ? err.message : String(err); args.logger.warn("opencode.inventory_probe_failed", { error: message }); - // If the server died, clear it so next probe creates a fresh one - if (sharedServer) { - forceKillServer(sharedServer); - sharedServer = null; - } replaceDynamicOpenCodeModelDescriptors([]); inventoryCache = { cachedAt: Date.now(), diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts new file mode 100644 index 000000000..b9c9a1323 --- /dev/null +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts @@ -0,0 +1,441 @@ +import { createHash } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { stableStringify } from "../shared/utils"; + +const originalFetch = global.fetch; + +const mockState = vi.hoisted(() => { + let nextSessionId = 1; + const makeLease = (url: string) => ({ + url, + release: vi.fn(), + close: vi.fn(), + touch: vi.fn(), + setBusy: vi.fn(), + setEvictionHandler: vi.fn(), + }); + + return { + resetSessionIds: () => { + nextSessionId = 1; + }, + sharedLease: makeLease("http://127.0.0.1:4101"), + dedicatedLease: makeLease("http://127.0.0.1:4102"), + createSession: vi.fn(async () => ({ + data: { id: `opencode-session-${nextSessionId++}` }, + })), + getSession: vi.fn(async () => { + throw new Error("session not found"); + }), + }; +}); + +vi.mock("@opencode-ai/sdk", () => ({ + createOpencodeClient: vi.fn(() => ({ + session: { + create: mockState.createSession, + get: mockState.getSession, + }, + })), +})); + +vi.mock("./openCodeBinaryManager", () => ({ + resolveOpenCodeBinaryPath: vi.fn(() => "/Users/admin/.opencode/bin/opencode"), +})); + +vi.mock("./openCodeServerManager", () => ({ + acquireSharedOpenCodeServer: vi.fn(async () => mockState.sharedLease), + acquireDedicatedOpenCodeServer: vi.fn(async () => mockState.dedicatedLease), + getOpenCodeRuntimeDiagnostics: vi.fn(() => ({ + sharedCount: 1, + dedicatedCount: 0, + entries: [], + })), +})); + +import { + __resetOpenCodeRuntimeDiagnosticsForTests, + getOpenCodeRuntimeSnapshot, + startOpenCodeSession, +} from "./openCodeRuntime"; +import { + acquireDedicatedOpenCodeServer, + acquireSharedOpenCodeServer, +} from "./openCodeServerManager"; + +function createLaunch(overrides: Partial> = {}) { + return { + mode: "bundled_proxy" as const, + command: "node", + cmdArgs: ["dist/main/adeMcpProxy.cjs", "--project-root", "/repo", "--workspace-root", "/repo"], + env: { + ADE_PROJECT_ROOT: "/repo", + ADE_WORKSPACE_ROOT: "/repo", + ADE_DEFAULT_ROLE: "agent", + ...overrides, + }, + entryPath: "dist/main/adeMcpProxy.cjs", + runtimeRoot: null, + socketPath: "/tmp/ade.sock", + packaged: false, + resourcesPath: null, + }; +} + +function sanitizeNamePart(value: string | null | undefined, fallback: string): string { + const normalized = (value?.trim() ?? "") + .replace(/[^a-zA-Z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return normalized.length > 0 ? normalized.slice(0, 48) : fallback; +} + +function expectedDynamicServerName(args: { + ownerKind: string; + ownerId?: string | null; + ownerKey?: string | null; + sessionId?: string; + launch: ReturnType; +}): string { + const identity = sanitizeNamePart( + args.ownerId ?? args.ownerKey ?? args.sessionId, + "session", + ); + const launchFingerprint = createHash("sha1") + .update(stableStringify({ + command: args.launch.command, + cmdArgs: args.launch.cmdArgs, + env: args.launch.env, + })) + .digest("hex") + .slice(0, 10); + return `ade_session_${sanitizeNamePart(args.ownerKind, "owner")}_${identity}_${launchFingerprint}`; +} + +describe("openCodeRuntime dynamic ADE MCP registration", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState.resetSessionIds(); + __resetOpenCodeRuntimeDiagnosticsForTests(); + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("registers a per-session ADE MCP server on the shared OpenCode runtime and scopes tools to it", async () => { + const launch = createLaunch({ + ADE_CHAT_SESSION_ID: "chat-1", + }); + const serverName = expectedDynamicServerName({ + ownerKind: "chat", + ownerId: "chat-1", + ownerKey: "chat:chat-1", + launch, + }); + + vi.mocked(global.fetch).mockResolvedValueOnce(new Response("{}", { status: 200 })); + vi.mocked(global.fetch).mockResolvedValueOnce(new Response("{}", { status: 200 })); + vi.mocked(global.fetch).mockResolvedValueOnce(new Response("true", { status: 200 })); + + const handle = await startOpenCodeSession({ + directory: "/repo", + title: "Shared chat", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: launch, + ownerKind: "chat", + ownerId: "chat-1", + ownerKey: "chat:chat-1", + }); + + expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(1); + expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); + expect(handle.toolSelection).toEqual(expect.objectContaining({ + "ade_session_*": false, + })); + + const enabledToolPattern = Object.entries(handle.toolSelection ?? {}).find(([, enabled]) => enabled === true)?.[0]; + expect(enabledToolPattern).toMatch(/^ade_session_chat_chat-1_[a-f0-9]{10}_\*$/); + + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + href: "http://127.0.0.1:4101/mcp?directory=%2Frepo", + }), + expect.objectContaining({ + method: "GET", + }), + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + href: "http://127.0.0.1:4101/mcp?directory=%2Frepo", + }), + expect.objectContaining({ + method: "POST", + body: expect.stringContaining("\"name\":\"ade_session_chat_chat-1_"), + }), + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + href: `http://127.0.0.1:4101/mcp/${encodeURIComponent(serverName)}/connect?directory=%2Frepo`, + }), + expect.objectContaining({ + method: "POST", + }), + ); + + handle.close("handle_close"); + await Promise.resolve(); + + expect(global.fetch).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + href: `http://127.0.0.1:4101/mcp/${encodeURIComponent(serverName)}/disconnect?directory=%2Frepo`, + }), + expect.objectContaining({ + method: "POST", + }), + ); + }); + + it("reuses an already-connected ADE MCP registration without reconnecting it", async () => { + const launch = createLaunch({ + ADE_CHAT_SESSION_ID: "chat-connected", + }); + const serverName = expectedDynamicServerName({ + ownerKind: "chat", + ownerId: "chat-connected", + ownerKey: "chat:chat-connected", + launch, + }); + vi.mocked(global.fetch).mockResolvedValueOnce(new Response(JSON.stringify({ + [serverName]: { status: "connected" }, + }), { status: 200 })); + + const handle = await startOpenCodeSession({ + directory: "/repo", + title: "Connected chat", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: launch, + ownerKind: "chat", + ownerId: "chat-connected", + ownerKey: "chat:chat-connected", + }); + + expect(vi.mocked(global.fetch)).toHaveBeenCalledTimes(1); + expect(acquireDedicatedOpenCodeServer).not.toHaveBeenCalled(); + expect(handle.toolSelection).toEqual(expect.objectContaining({ + "ade_session_*": false, + [`${serverName}_*`]: true, + })); + }); + + it("records dynamic MCP fallback diagnostics for observability", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(new Response("{}", { status: 200 })); + vi.mocked(global.fetch).mockResolvedValueOnce(new Response("{}", { status: 200 })); + vi.mocked(global.fetch).mockResolvedValueOnce(new Response("true", { status: 200 })); + + const successHandle = await startOpenCodeSession({ + directory: "/repo", + title: "Success chat", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: createLaunch({ + ADE_CHAT_SESSION_ID: "chat-success", + }), + ownerKind: "chat", + ownerId: "chat-success", + ownerKey: "chat:chat-success", + }); + successHandle.close("handle_close"); + await Promise.resolve(); + + vi.mocked(global.fetch).mockRejectedValue(new Error("mcp unavailable")); + await startOpenCodeSession({ + directory: "/repo", + title: "Fallback chat", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: createLaunch({ + ADE_CHAT_SESSION_ID: "chat-fallback-stats", + }), + ownerKind: "chat", + ownerId: "chat-fallback-stats", + ownerKey: "chat:chat-fallback-stats", + }); + + const snapshot = getOpenCodeRuntimeSnapshot(); + expect(snapshot.sharedCount).toBe(1); + expect(snapshot.dynamicMcp.registrationAttempts).toBe(2); + expect(snapshot.dynamicMcp.successfulRegistrations).toBe(1); + expect(snapshot.dynamicMcp.fallbackCount).toBe(1); + expect(snapshot.dynamicMcp.lastFallbackOwnerKind).toBe("chat"); + expect(snapshot.dynamicMcp.lastFallbackOwnerId).toBe("chat-fallback-stats"); + expect(snapshot.dynamicMcp.lastFallbackError).toContain("mcp unavailable"); + expect(snapshot.dynamicMcp.lastFallbackAt).toEqual(expect.any(String)); + }); + + it("creates distinct tool scopes for different ADE chat identities on the same shared server", async () => { + vi.mocked(global.fetch).mockImplementation(async () => new Response("{}", { status: 200 })); + + const handleA = await startOpenCodeSession({ + directory: "/repo", + title: "Chat A", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: createLaunch({ + ADE_CHAT_SESSION_ID: "chat-a", + }), + ownerKind: "chat", + ownerId: "chat-a", + ownerKey: "chat:chat-a", + }); + + const handleB = await startOpenCodeSession({ + directory: "/repo", + title: "Chat B", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: createLaunch({ + ADE_CHAT_SESSION_ID: "chat-b", + }), + ownerKind: "chat", + ownerId: "chat-b", + ownerKey: "chat:chat-b", + }); + + const enabledA = Object.keys(handleA.toolSelection ?? {}).find((key) => key !== "ade_session_*"); + const enabledB = Object.keys(handleB.toolSelection ?? {}).find((key) => key !== "ade_session_*"); + + expect(enabledA).toBeTruthy(); + expect(enabledB).toBeTruthy(); + expect(enabledA).not.toBe(enabledB); + expect(enabledA).toContain("chat-a"); + expect(enabledB).toContain("chat-b"); + }); + + it("reuses the same dynamic ADE MCP server name when launch env key order differs", async () => { + vi.mocked(global.fetch).mockImplementation(async () => new Response("{}", { status: 200 })); + + const handleA = await startOpenCodeSession({ + directory: "/repo", + title: "Chat A", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: createLaunch({ + ADE_CHAT_SESSION_ID: "chat-stable", + ADE_OWNER_ID: "owner-stable", + }), + ownerKind: "chat", + ownerId: "chat-stable", + ownerKey: "chat:chat-stable", + }); + + const handleB = await startOpenCodeSession({ + directory: "/repo", + title: "Chat A", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: { + ...createLaunch(), + env: { + ADE_OWNER_ID: "owner-stable", + ADE_DEFAULT_ROLE: "agent", + ADE_WORKSPACE_ROOT: "/repo", + ADE_PROJECT_ROOT: "/repo", + ADE_CHAT_SESSION_ID: "chat-stable", + }, + }, + ownerKind: "chat", + ownerId: "chat-stable", + ownerKey: "chat:chat-stable", + }); + + const enabledA = Object.keys(handleA.toolSelection ?? {}).find((key) => key !== "ade_session_*"); + const enabledB = Object.keys(handleB.toolSelection ?? {}).find((key) => key !== "ade_session_*"); + + expect(enabledA).toBeTruthy(); + expect(enabledA).toBe(enabledB); + }); + + it("falls back to a dedicated static ADE MCP launch when dynamic registration fails", async () => { + vi.mocked(global.fetch).mockRejectedValue(new Error("mcp unavailable")); + const logger = { warn: vi.fn() } as any; + + await startOpenCodeSession({ + directory: "/repo", + title: "Fallback chat", + leaseKind: "shared", + projectConfig: { ai: {} }, + dynamicMcpLaunch: createLaunch({ + ADE_CHAT_SESSION_ID: "chat-fallback", + }), + ownerKind: "chat", + ownerId: "chat-fallback", + ownerKey: "chat:chat-fallback", + logger, + }); + + expect(acquireSharedOpenCodeServer).toHaveBeenCalledTimes(1); + expect(mockState.sharedLease.close).toHaveBeenCalledWith("error"); + expect(acquireDedicatedOpenCodeServer).toHaveBeenCalledTimes(1); + expect(acquireDedicatedOpenCodeServer).toHaveBeenCalledWith(expect.objectContaining({ + config: expect.objectContaining({ + mcp: expect.objectContaining({ + ade: expect.objectContaining({ + type: "local", + environment: expect.objectContaining({ + ADE_CHAT_SESSION_ID: "chat-fallback", + }), + }), + }), + }), + })); + expect(logger.warn).toHaveBeenCalledWith( + "opencode.dynamic_mcp_attach_failed", + expect.objectContaining({ + ownerKind: "chat", + ownerId: "chat-fallback", + }), + ); + }); + + 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", + }), + 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(5); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts index c7f4602f8..f1d8336c2 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts @@ -1,8 +1,8 @@ +import { createHash, randomUUID } from "node:crypto"; import { createServer } from "node:net"; import { pathToFileURL } from "node:url"; import { createOpencodeClient, - createOpencodeServer, type Config as OpenCodeConfig, type Event, type FilePartInput, @@ -16,10 +16,26 @@ import { type LocalProviderFamily, type ModelDescriptor, } from "../../../shared/modelRegistry"; -import type { AiLocalProviderConfigs, EffectiveProjectConfig, ProjectConfigFile } from "../../../shared/types"; +import type { + AiLocalProviderConfigs, + EffectiveProjectConfig, + OpenCodeDynamicMcpDiagnostics, + OpenCodeRuntimeSnapshot, + ProjectConfigFile, +} from "../../../shared/types"; +import { stableStringify } from "../shared/utils"; import { resolveOpenCodeBinaryPath } from "./openCodeBinaryManager"; import type { PermissionMode } from "../ai/tools/universalTools"; import type { AdeMcpLaunch } from "../runtime/adeMcpLaunch"; +import type { Logger } from "../logging/logger"; +import { + acquireDedicatedOpenCodeServer, + acquireSharedOpenCodeServer, + getOpenCodeRuntimeDiagnostics, + type OpenCodeServerLease, + type OpenCodeServerOwnerKind, + type OpenCodeServerShutdownReason, +} from "./openCodeServerManager"; export type OpenCodeAgentProfile = "ade-plan" | "ade-edit" | "ade-full-auto" | "ade-helper"; @@ -29,9 +45,14 @@ export type OpenCodeSessionHandle = { url: string; close(): void; }; + lease: OpenCodeServerLease; sessionId: string; directory: string; - close(): void; + toolSelection: Record | null; + close(reason?: OpenCodeServerShutdownReason): void; + touch(): void; + setBusy(busy: boolean): void; + setEvictionHandler(handler: ((reason: OpenCodeServerShutdownReason) => void) | null): void; }; export type OpenCodePromptFile = { @@ -58,6 +79,12 @@ type StartOpenCodeSessionArgs = BuildOpenCodeConfigArgs & { directory: string; title: string; sessionId?: string; + ownerKind?: OpenCodeServerOwnerKind; + ownerId?: string | null; + ownerKey?: string | null; + leaseKind?: "shared" | "dedicated"; + dynamicMcpLaunch?: AdeMcpLaunch; + logger?: Logger | null; }; type RunOpenCodePromptArgs = BuildOpenCodeConfigArgs & { @@ -145,6 +172,195 @@ function buildPermissionConfig( }; } +function fingerprintOpenCodeConfig(config: OpenCodeConfig): string { + return stableStringify(config); +} + +export function buildSharedOpenCodeServerKey(config: OpenCodeConfig): string { + return `shared:${fingerprintOpenCodeConfig(config)}`; +} + +function sanitizeDynamicMcpNamePart(value: string | null | undefined, fallback: string): string { + const normalized = (value?.trim() ?? "") + .replace(/[^a-zA-Z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return normalized.length > 0 ? normalized.slice(0, 48) : fallback; +} + +function fingerprintAdeMcpLaunch(launch: AdeMcpLaunch): string { + return createHash("sha1") + .update(stableStringify({ + command: launch.command, + cmdArgs: launch.cmdArgs, + env: launch.env, + })) + .digest("hex") + .slice(0, 10); +} + +function buildDynamicAdeMcpServerName(args: { + ownerKind: OpenCodeServerOwnerKind; + ownerId?: string | null; + ownerKey?: string | null; + sessionId?: string; + launch: AdeMcpLaunch; +}): string { + const identity = sanitizeDynamicMcpNamePart( + args.ownerId ?? args.ownerKey ?? args.sessionId, + "session", + ); + return `ade_session_${sanitizeDynamicMcpNamePart(args.ownerKind, "owner")}_${identity}_${fingerprintAdeMcpLaunch(args.launch)}`; +} + +function buildDynamicAdeToolSelection(serverName: string): Record { + return { + "ade_session_*": false, + [`${serverName}_*`]: true, + }; +} + +type OpenCodeMcpStatus = { + status?: string; + error?: string; +}; + +function readDynamicMcpStatus( + payload: Record | null, + serverName: string, +): OpenCodeMcpStatus | null { + if (!payload || typeof payload !== "object") return null; + const entry = payload[serverName]; + if (!entry || typeof entry !== "object") return null; + return entry as OpenCodeMcpStatus; +} + +const DYNAMIC_ADE_MCP_REGISTRATION_ATTEMPTS = 3; +const DYNAMIC_ADE_MCP_REGISTRATION_RETRY_DELAY_MS = 150; +const dynamicMcpDiagnostics: OpenCodeDynamicMcpDiagnostics = { + registrationAttempts: 0, + successfulRegistrations: 0, + retryCount: 0, + fallbackCount: 0, + lastFallbackAt: null, + lastFallbackOwnerKind: null, + lastFallbackOwnerId: null, + lastFallbackError: null, +}; + +async function wait(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function callOpenCodeServer(args: { + baseUrl: string; + directory: string; + path: string; + method?: "GET" | "POST"; + body?: unknown; +}): Promise { + const url = new URL(args.path, args.baseUrl); + if (args.directory.trim().length > 0) { + url.searchParams.set("directory", args.directory); + } + const response = await fetch(url, { + method: args.method ?? "GET", + headers: args.body === undefined ? undefined : { + "content-type": "application/json", + }, + body: args.body === undefined ? undefined : JSON.stringify(args.body), + }); + if (!response.ok) { + const detail = (await response.text()).trim(); + throw new Error( + `OpenCode server request ${args.method ?? "GET"} ${args.path} failed (${response.status})${detail ? `: ${detail}` : ""}.`, + ); + } + if (response.status === 204) return null; + const text = (await response.text()).trim(); + if (!text.length) return null; + return JSON.parse(text) as T; +} + +async function ensureDynamicAdeMcpRegistration(args: { + baseUrl: string; + directory: string; + ownerKind: OpenCodeServerOwnerKind; + ownerId?: string | null; + ownerKey?: string | null; + sessionId?: string; + launch: AdeMcpLaunch; +}): Promise<{ + serverName: string; + toolSelection: Record; + disconnect(): Promise; +}> { + const serverName = buildDynamicAdeMcpServerName(args); + dynamicMcpDiagnostics.registrationAttempts += 1; + let lastError: unknown = null; + for (let attempt = 0; attempt < DYNAMIC_ADE_MCP_REGISTRATION_ATTEMPTS; attempt += 1) { + try { + let status = readDynamicMcpStatus(await callOpenCodeServer>({ + baseUrl: args.baseUrl, + directory: args.directory, + path: "/mcp", + }), serverName); + if (!status) { + status = readDynamicMcpStatus(await callOpenCodeServer>({ + baseUrl: args.baseUrl, + directory: args.directory, + path: "/mcp", + method: "POST", + body: { + name: serverName, + config: { + type: "local", + command: [args.launch.command, ...args.launch.cmdArgs], + environment: args.launch.env, + }, + }, + }), serverName); + } + if (status?.status !== "connected") { + await callOpenCodeServer({ + baseUrl: args.baseUrl, + directory: args.directory, + path: `/mcp/${encodeURIComponent(serverName)}/connect`, + method: "POST", + }); + } + lastError = null; + dynamicMcpDiagnostics.successfulRegistrations += 1; + break; + } catch (error) { + lastError = error; + if (attempt >= DYNAMIC_ADE_MCP_REGISTRATION_ATTEMPTS - 1) { + throw error; + } + dynamicMcpDiagnostics.retryCount += 1; + await wait(DYNAMIC_ADE_MCP_REGISTRATION_RETRY_DELAY_MS); + } + } + if (lastError) { + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + + let disconnected = false; + return { + serverName, + toolSelection: buildDynamicAdeToolSelection(serverName), + async disconnect(): Promise { + if (disconnected) return; + disconnected = true; + await callOpenCodeServer({ + baseUrl: args.baseUrl, + directory: args.directory, + path: `/mcp/${encodeURIComponent(serverName)}/disconnect`, + method: "POST", + }).catch(() => {}); + }, + }; +} + export function buildOpenCodeMergedConfig(args: BuildOpenCodeConfigArgs): OpenCodeConfig { return buildOpenCodeConfig(args); } @@ -285,35 +501,6 @@ async function findAvailablePort(): Promise { }); } -function isPortConflict(error: unknown): boolean { - if (error && typeof error === "object") { - if ("code" in error && error.code === "EADDRINUSE") return true; - if (error instanceof Error) { - return error.message.includes("EADDRINUSE") || error.message.includes("address already in use"); - } - } - return false; -} - -const PORT_RETRY_ATTEMPTS = 3; - -export async function createOpencodeServerWithRetry( - config: OpenCodeConfig, -): Promise<{ port: number; server: Awaited> }> { - let lastError: unknown; - for (let attempt = 0; attempt < PORT_RETRY_ATTEMPTS; attempt++) { - const port = await findAvailablePort(); - try { - const server = await createOpencodeServer({ port, config }); - return { port, server }; - } catch (error) { - lastError = error; - if (!isPortConflict(error)) throw error; - } - } - throw lastError; -} - export async function allocateOpenCodeEphemeralPort(): Promise { return await findAvailablePort(); } @@ -367,15 +554,90 @@ export function buildOpenCodePromptParts(args: { return parts; } -export async function startOpenCodeSession( +function createOpenCodeSessionHandle(args: { + client: OpencodeClient; + lease: OpenCodeServerLease; + sessionId: string; + directory: string; + dynamicMcp?: Awaited> | null; +}): OpenCodeSessionHandle { + return { + client: args.client, + server: { + url: args.lease.url, + close() { + args.dynamicMcp?.disconnect().catch(() => {}); + args.lease.close("handle_close"); + }, + }, + lease: args.lease, + sessionId: args.sessionId, + directory: args.directory, + toolSelection: args.dynamicMcp?.toolSelection ?? null, + close(reason = "handle_close") { + args.dynamicMcp?.disconnect().catch(() => {}); + args.lease.close(reason); + }, + touch() { + args.lease.touch(); + }, + setBusy(busy: boolean) { + args.lease.setBusy(busy); + }, + setEvictionHandler(handler) { + args.lease.setEvictionHandler(handler); + }, + }; +} + +async function startOpenCodeSessionInternal( args: StartOpenCodeSessionArgs, ): Promise { - ensureOpenCodeAvailable(); - const { server } = await createOpencodeServerWithRetry(buildOpenCodeConfig(args)); + const config = buildOpenCodeConfig(args); + const configFingerprint = fingerprintOpenCodeConfig(config); + const ownerKind = args.ownerKind ?? "oneshot"; + const leaseKind = args.leaseKind ?? "dedicated"; + const ownerKey = args.ownerKey?.trim() + || (leaseKind === "dedicated" + ? `${ownerKind}:${args.ownerId?.trim() || args.sessionId?.trim() || `${args.directory}:${args.title}:${randomUUID()}`}` + : null); + const lease = leaseKind === "shared" + ? await acquireSharedOpenCodeServer({ + config, + key: buildSharedOpenCodeServerKey(config), + ownerKind, + ownerId: args.ownerId, + logger: args.logger, + }) + : await acquireDedicatedOpenCodeServer({ + ownerKey: ownerKey ?? `dedicated:${ownerKind}:${randomUUID()}`, + config, + ownerKind, + ownerId: args.ownerId, + logger: args.logger, + }); const client = createOpencodeClient({ - baseUrl: server.url, + baseUrl: lease.url, directory: args.directory, }); + let dynamicMcp: Awaited> | null = null; + try { + if (args.dynamicMcpLaunch) { + dynamicMcp = await ensureDynamicAdeMcpRegistration({ + baseUrl: lease.url, + directory: args.directory, + ownerKind, + ownerId: args.ownerId, + ownerKey, + sessionId: args.sessionId, + launch: args.dynamicMcpLaunch, + }); + } + } catch (error) { + lease.close("error"); + throw error; + } + const resolvedSessionId = trimToUndefined(args.sessionId); if (resolvedSessionId) { @@ -384,15 +646,13 @@ export async function startOpenCodeSession( path: { id: resolvedSessionId }, query: { directory: args.directory }, }); - return { + return createOpenCodeSessionHandle({ client, - server, + lease, sessionId: resolvedSessionId, directory: args.directory, - close() { - server.close(); - }, - }; + dynamicMcp, + }); } catch { // Fall through to session creation when the persisted session no longer exists. } @@ -404,21 +664,71 @@ export async function startOpenCodeSession( }); if (!created.data) { - server.close(); + dynamicMcp?.disconnect().catch(() => {}); + lease.close("error"); throw new Error("OpenCode session.create returned no session payload."); } - return { + return createOpenCodeSessionHandle({ client, - server, + lease, sessionId: created.data.id, directory: args.directory, - close() { - server.close(); - }, + dynamicMcp, + }); +} + +export async function startOpenCodeSession( + args: StartOpenCodeSessionArgs, +): Promise { + ensureOpenCodeAvailable(); + if (args.dynamicMcpLaunch) { + try { + return await startOpenCodeSessionInternal({ + ...args, + 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); + args.logger?.warn("opencode.dynamic_mcp_attach_failed", { + ownerKind: args.ownerKind ?? "oneshot", + ownerId: args.ownerId ?? null, + sessionId: args.sessionId ?? null, + error: error instanceof Error ? error.message : String(error), + }); + return await startOpenCodeSessionInternal({ + ...args, + dynamicMcpLaunch: undefined, + mcpLaunch: args.dynamicMcpLaunch, + leaseKind: "dedicated", + }); + } + } + return await startOpenCodeSessionInternal(args); +} + +export function getOpenCodeRuntimeSnapshot(): OpenCodeRuntimeSnapshot { + return { + ...getOpenCodeRuntimeDiagnostics(), + dynamicMcp: { ...dynamicMcpDiagnostics }, }; } +export function __resetOpenCodeRuntimeDiagnosticsForTests(): void { + dynamicMcpDiagnostics.registrationAttempts = 0; + dynamicMcpDiagnostics.successfulRegistrations = 0; + dynamicMcpDiagnostics.retryCount = 0; + dynamicMcpDiagnostics.fallbackCount = 0; + dynamicMcpDiagnostics.lastFallbackAt = null; + dynamicMcpDiagnostics.lastFallbackOwnerKind = null; + dynamicMcpDiagnostics.lastFallbackOwnerId = null; + dynamicMcpDiagnostics.lastFallbackError = null; +} + export async function openCodeEventStream(args: { client: OpencodeClient; directory: string; @@ -439,6 +749,8 @@ export async function runOpenCodeTextPrompt( title: args.title, mcpLaunch: args.mcpLaunch, projectConfig: args.projectConfig, + leaseKind: args.mcpLaunch ? "dedicated" : "shared", + ownerKind: "oneshot", }); const model = resolveOpenCodeModelSelection(args.modelDescriptor); @@ -497,6 +809,6 @@ export async function runOpenCodeTextPrompt( return { text: text.trim(), inputTokens, outputTokens }; } finally { args.signal?.removeEventListener("abort", forwardAbort); - handle.close(); + handle.close("handle_close"); } } diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts new file mode 100644 index 000000000..42e703224 --- /dev/null +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => ({ + created: [] as Array<{ close: ReturnType; url: string }>, +})); + +vi.mock("@opencode-ai/sdk", () => ({ + createOpencodeServer: vi.fn(async ({ port }: { port: number }) => { + const close = vi.fn(); + const entry = { + close, + url: `http://127.0.0.1:${port}`, + }; + mockState.created.push(entry); + return entry; + }), +})); + +import { + __resetOpenCodeServerManagerForTests, + acquireDedicatedOpenCodeServer, + acquireSharedOpenCodeServer, + getOpenCodeRuntimeDiagnostics, +} from "./openCodeServerManager"; + +describe("openCodeServerManager", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockState.created.length = 0; + __resetOpenCodeServerManagerForTests(); + }); + + afterEach(() => { + __resetOpenCodeServerManagerForTests(); + vi.useRealTimers(); + }); + + it("reuses shared servers until the idle TTL expires", async () => { + const config = { share: "disabled", autoupdate: false, snapshot: false } as const; + const leaseA = await acquireSharedOpenCodeServer({ + config, + key: "shared:test", + ownerKind: "inventory", + idleTtlMs: 1_000, + }); + const leaseB = await acquireSharedOpenCodeServer({ + config, + key: "shared:test", + ownerKind: "inventory", + idleTtlMs: 1_000, + }); + + expect(mockState.created).toHaveLength(1); + expect(leaseA.url).toBe(leaseB.url); + expect(getOpenCodeRuntimeDiagnostics().sharedCount).toBe(1); + + leaseA.release("handle_close"); + leaseB.release("handle_close"); + expect(mockState.created[0]?.close).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(mockState.created[0]?.close).toHaveBeenCalledTimes(1); + expect(getOpenCodeRuntimeDiagnostics().sharedCount).toBe(0); + }); + + it("treats semantically identical shared configs as the same runtime even when key order differs", async () => { + const configA = { + share: "disabled", + autoupdate: false, + snapshot: false, + provider: { + zed: { options: { apiKey: "one", baseURL: "https://example.test" } }, + alpha: { options: { apiKey: "two" } }, + }, + } as const; + const configB = { + snapshot: false, + provider: { + alpha: { options: { apiKey: "two" } }, + zed: { options: { baseURL: "https://example.test", apiKey: "one" } }, + }, + autoupdate: false, + share: "disabled", + } as const; + + const leaseA = await acquireSharedOpenCodeServer({ + config: configA, + ownerKind: "chat", + ownerId: "chat-a", + idleTtlMs: 1_000, + }); + const leaseB = await acquireSharedOpenCodeServer({ + config: configB, + ownerKind: "chat", + ownerId: "chat-b", + idleTtlMs: 1_000, + }); + + expect(mockState.created).toHaveLength(1); + expect(leaseA.url).toBe(leaseB.url); + expect(getOpenCodeRuntimeDiagnostics().sharedCount).toBe(1); + + leaseA.release("handle_close"); + leaseB.release("handle_close"); + }); + + it("keeps shared leased servers alive when a config change is requested", async () => { + const configA = { share: "disabled", autoupdate: false, snapshot: false } as const; + const configB = { + share: "disabled", + autoupdate: false, + snapshot: false, + provider: { + openai: { options: { apiKey: "new-key" } }, + }, + } as const; + + const leaseA = await acquireSharedOpenCodeServer({ + config: configA, + key: "shared:config-change", + ownerKind: "inventory", + idleTtlMs: 1_000, + }); + const firstClose = mockState.created[0]?.close; + + const leaseB = await acquireSharedOpenCodeServer({ + config: configB, + key: "shared:config-change", + ownerKind: "inventory", + idleTtlMs: 1_000, + }); + + expect(firstClose).not.toHaveBeenCalled(); + expect(leaseB.url).toBe(leaseA.url); + + const diagnostics = getOpenCodeRuntimeDiagnostics(); + expect(diagnostics.entries[0]?.configFingerprint).toMatch(/^[a-f0-9]{64}$/); + + leaseA.release("handle_close"); + leaseB.release("handle_close"); + }); + + it("shuts down a shared server immediately when its last lease closes with an error", async () => { + const config = { share: "disabled", autoupdate: false, snapshot: false } as const; + const lease = await acquireSharedOpenCodeServer({ + config, + key: "shared:error", + ownerKind: "chat", + idleTtlMs: 60_000, + }); + + expect(getOpenCodeRuntimeDiagnostics().sharedCount).toBe(1); + + lease.close("error"); + + expect(mockState.created[0]?.close).toHaveBeenCalledTimes(1); + expect(getOpenCodeRuntimeDiagnostics().sharedCount).toBe(0); + }); + + it("refuses to reclaim leased dedicated servers when the budget is exceeded", async () => { + const baseConfig = { share: "disabled", autoupdate: false, snapshot: false } as const; + + for (let index = 0; index < 6; index += 1) { + const lease = await acquireDedicatedOpenCodeServer({ + ownerKey: `chat:${index}`, + ownerKind: "chat", + ownerId: `chat-${index}`, + config: { + ...baseConfig, + agent: { + [`role-${index}`]: { + permission: { + edit: "ask", + bash: "ask", + webfetch: "allow", + doom_loop: "ask", + external_directory: "ask", + }, + }, + }, + } as const, + }); + lease.setBusy(false); + lease.setEvictionHandler(vi.fn()); + } + + await expect( + acquireDedicatedOpenCodeServer({ + ownerKey: "chat:blocked", + ownerKind: "chat", + ownerId: "chat-blocked", + config: { + ...baseConfig, + agent: { + blocked: { + permission: { + edit: "ask", + bash: "ask", + webfetch: "allow", + doom_loop: "ask", + external_directory: "ask", + }, + }, + }, + } as const, + }), + ).rejects.toThrow(/OpenCode runtime limit reached/); + expect(mockState.created).toHaveLength(6); + expect(mockState.created.every((entry) => entry.close.mock.calls.length === 0)).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts new file mode 100644 index 000000000..977c2cfe1 --- /dev/null +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts @@ -0,0 +1,488 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createServer } from "node:net"; +import { + createOpencodeServer, + type Config as OpenCodeConfig, +} from "@opencode-ai/sdk"; +import type { Logger } from "../logging/logger"; +import { stableStringify } from "../shared/utils"; + +export type OpenCodeServerLeaseKind = "shared" | "dedicated"; +export type OpenCodeServerOwnerKind = "inventory" | "oneshot" | "chat" | "coordinator"; +export type OpenCodeServerShutdownReason = + | "handle_close" + | "idle_ttl" + | "paused_run" + | "ended_session" + | "model_switch" + | "project_close" + | "budget_eviction" + | "shutdown" + | "config_changed" + | "error"; + +type OpenCodeServerInstance = Awaited>; + +type OpenCodeServerEntry = { + id: string; + key: string; + leaseKind: OpenCodeServerLeaseKind; + ownerKind: OpenCodeServerOwnerKind; + ownerId: string | null; + configFingerprint: string; + server: OpenCodeServerInstance; + idleTtlMs: number | null; + idleTimer: ReturnType | null; + refCount: number; + busy: boolean; + onEvict: ((reason: OpenCodeServerShutdownReason) => void) | null; + startedAt: number; + lastUsedAt: number; +}; + +export type OpenCodeServerLease = { + url: string; + release(reason?: OpenCodeServerShutdownReason): void; + close(reason?: OpenCodeServerShutdownReason): void; + touch(): void; + setBusy(busy: boolean): void; + setEvictionHandler(handler: ((reason: OpenCodeServerShutdownReason) => void) | null): void; +}; + +export type OpenCodeRuntimeDiagnosticsEntry = { + id: string; + key: string; + leaseKind: OpenCodeServerLeaseKind; + ownerKind: OpenCodeServerOwnerKind; + ownerId: string | null; + configFingerprint: string; + url: string; + busy: boolean; + refCount: number; + startedAt: number; + lastUsedAt: number; +}; + +const PORT_RETRY_ATTEMPTS = 3; +const DEFAULT_SHARED_IDLE_TTL_MS = 60_000; +const MAX_DEDICATED_OPENCODE_SERVERS = 6; + +const sharedEntries = new Map(); +const dedicatedEntries = new Map(); +const inFlightEntries = new Map>(); +const acquireQueues = new Map void>>(); + +function serializeConfigFingerprint(config: OpenCodeConfig): string { + return createHash("sha256").update(stableStringify(config)).digest("hex"); +} + +async function withAcquireLock(lockKey: string, fn: () => Promise): Promise { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const queue = acquireQueues.get(lockKey); + if (queue) { + queue.push(release); + await gate; + } else { + acquireQueues.set(lockKey, [release]); + release(); + } + + try { + return await fn(); + } finally { + const currentQueue = acquireQueues.get(lockKey); + if (currentQueue) { + currentQueue.shift(); + const next = currentQueue[0]; + if (next) { + next(); + } else { + acquireQueues.delete(lockKey); + } + } + } +} + +async function findAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Unable to allocate an OpenCode port."))); + return; + } + const port = address.port; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + }); +} + +function isPortConflict(error: unknown): boolean { + if (error && typeof error === "object") { + if ("code" in error && error.code === "EADDRINUSE") return true; + if (error instanceof Error) { + return error.message.includes("EADDRINUSE") || error.message.includes("address already in use"); + } + } + return false; +} + +async function createOpencodeServerWithRetry( + config: OpenCodeConfig, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < PORT_RETRY_ATTEMPTS; attempt += 1) { + const port = await findAvailablePort(); + try { + return await createOpencodeServer({ port, config }); + } catch (error) { + lastError = error; + if (!isPortConflict(error)) throw error; + } + } + throw lastError; +} + +function logRuntimeEvent( + logger: Logger | null | undefined, + event: string, + entry: OpenCodeServerEntry, + extra: Record = {}, +): void { + logger?.info(event, { + leaseKind: entry.leaseKind, + ownerKind: entry.ownerKind, + ownerId: entry.ownerId, + configFingerprint: entry.configFingerprint, + url: entry.server.url, + ...extra, + }); +} + +function clearIdleTimer(entry: OpenCodeServerEntry): void { + if (entry.idleTimer) { + clearTimeout(entry.idleTimer); + entry.idleTimer = null; + } +} + +function removeEntry(entry: OpenCodeServerEntry): void { + clearIdleTimer(entry); + if (entry.leaseKind === "shared") { + sharedEntries.delete(entry.key); + return; + } + dedicatedEntries.delete(entry.key); +} + +function shutdownEntry( + entry: OpenCodeServerEntry, + reason: OpenCodeServerShutdownReason, + logger?: Logger | null, +): void { + removeEntry(entry); + try { + entry.server.close(); + } catch { + // ignore shutdown failures + } + logRuntimeEvent(logger, "opencode.server_shutdown", entry, { reason }); +} + +function scheduleSharedIdleTimer( + entry: OpenCodeServerEntry, + logger?: Logger | null, +): void { + clearIdleTimer(entry); + if (!entry.idleTtlMs || entry.refCount > 0) return; + entry.idleTimer = setTimeout(() => { + const current = sharedEntries.get(entry.key); + if (!current || current.id !== entry.id || current.refCount > 0) return; + shutdownEntry(current, "idle_ttl", logger); + }, entry.idleTtlMs); + if (entry.idleTimer.unref) entry.idleTimer.unref(); +} + +function buildLease(entry: OpenCodeServerEntry, logger?: Logger | null): OpenCodeServerLease { + let released = false; + const touch = (): void => { + entry.lastUsedAt = Date.now(); + }; + const release = (reason: OpenCodeServerShutdownReason = "handle_close"): void => { + if (released) return; + released = true; + entry.refCount = Math.max(0, entry.refCount - 1); + entry.lastUsedAt = Date.now(); + logRuntimeEvent(logger, "opencode.server_released", entry, { reason, refCount: entry.refCount }); + if (entry.leaseKind === "shared") { + if (entry.refCount === 0 && reason === "error") { + shutdownEntry(entry, reason, logger); + return; + } + scheduleSharedIdleTimer(entry, logger); + return; + } + if (entry.refCount === 0) { + shutdownEntry(entry, reason, logger); + } + }; + touch(); + return { + url: entry.server.url, + release, + close(reason = "handle_close") { + release(reason); + }, + touch, + setBusy(busy: boolean) { + entry.busy = busy; + entry.lastUsedAt = Date.now(); + }, + setEvictionHandler(handler) { + entry.onEvict = handler; + }, + }; +} + +function pickDedicatedEvictionCandidate(excludeKey?: string): OpenCodeServerEntry | null { + let oldest: OpenCodeServerEntry | null = null; + for (const entry of dedicatedEntries.values()) { + if (entry.key === excludeKey) continue; + if (entry.busy || entry.refCount > 0) continue; + if (!oldest || entry.lastUsedAt < oldest.lastUsedAt) { + oldest = entry; + } + } + return oldest; +} + +function enforceDedicatedBudget( + logger?: Logger | null, + excludeKey?: string, +): void { + if (dedicatedEntries.size < MAX_DEDICATED_OPENCODE_SERVERS) return; + const candidate = pickDedicatedEvictionCandidate(excludeKey); + if (!candidate) { + throw new Error( + `OpenCode runtime limit reached (${MAX_DEDICATED_OPENCODE_SERVERS} dedicated servers). Close or wait for an idle chat/mission runtime before starting another OpenCode session.`, + ); + } + candidate.onEvict?.("budget_eviction"); + const stillPresent = dedicatedEntries.get(candidate.key); + if (stillPresent) { + if (stillPresent.refCount > 0 || stillPresent.busy) { + throw new Error( + `OpenCode runtime limit reached (${MAX_DEDICATED_OPENCODE_SERVERS} dedicated servers). The selected eviction candidate is still leased and cannot be reclaimed safely.`, + ); + } + shutdownEntry(stillPresent, "budget_eviction", logger); + } +} + +async function createEntry(args: { + key: string; + leaseKind: OpenCodeServerLeaseKind; + ownerKind: OpenCodeServerOwnerKind; + ownerId?: string | null; + config: OpenCodeConfig; + configFingerprint: string; + idleTtlMs?: number | null; + logger?: Logger | null; +}): Promise { + const inflightKey = `${args.leaseKind}:${args.key}:${args.configFingerprint}`; + const existingPromise = inFlightEntries.get(inflightKey); + if (existingPromise) return await existingPromise; + + const createPromise = (async () => { + const server = await createOpencodeServerWithRetry(args.config); + const entry: OpenCodeServerEntry = { + id: randomUUID(), + key: args.key, + leaseKind: args.leaseKind, + ownerKind: args.ownerKind, + ownerId: args.ownerId?.trim() || null, + configFingerprint: args.configFingerprint, + server, + idleTtlMs: args.leaseKind === "shared" ? args.idleTtlMs ?? DEFAULT_SHARED_IDLE_TTL_MS : null, + idleTimer: null, + refCount: 0, + busy: false, + onEvict: null, + startedAt: Date.now(), + lastUsedAt: Date.now(), + }; + logRuntimeEvent(args.logger, "opencode.server_started", entry); + return entry; + })().finally(() => { + inFlightEntries.delete(inflightKey); + }); + + inFlightEntries.set(inflightKey, createPromise); + return await createPromise; +} + +export async function acquireSharedOpenCodeServer(args: { + config: OpenCodeConfig; + key?: string; + ownerKind?: OpenCodeServerOwnerKind; + ownerId?: string | null; + idleTtlMs?: number | null; + logger?: Logger | null; +}): Promise { + const configFingerprint = serializeConfigFingerprint(args.config); + const key = args.key?.trim() || configFingerprint; + return await withAcquireLock(`shared:${key}`, async () => { + while (true) { + const existing = sharedEntries.get(key); + if (existing && existing.configFingerprint === configFingerprint) { + clearIdleTimer(existing); + existing.refCount += 1; + existing.lastUsedAt = Date.now(); + logRuntimeEvent(args.logger, "opencode.server_reused", existing, { refCount: existing.refCount }); + return buildLease(existing, args.logger); + } + if (existing && existing.refCount > 0) { + existing.lastUsedAt = Date.now(); + logRuntimeEvent(args.logger, "opencode.server_config_mismatch_retained", existing, { + requestedConfigFingerprint: configFingerprint, + refCount: existing.refCount, + }); + existing.refCount += 1; + return buildLease(existing, args.logger); + } + if (existing) { + shutdownEntry(existing, "config_changed", args.logger); + } + const entry = await createEntry({ + key, + leaseKind: "shared", + ownerKind: args.ownerKind ?? "oneshot", + ownerId: args.ownerId, + config: args.config, + configFingerprint, + idleTtlMs: args.idleTtlMs, + logger: args.logger, + }); + if (entry.configFingerprint !== configFingerprint) { + shutdownEntry(entry, "config_changed", args.logger); + continue; + } + entry.refCount = 1; + sharedEntries.set(key, entry); + return buildLease(entry, args.logger); + } + }); +} + +export async function acquireDedicatedOpenCodeServer(args: { + ownerKey: string; + config: OpenCodeConfig; + ownerKind: OpenCodeServerOwnerKind; + ownerId?: string | null; + logger?: Logger | null; +}): Promise { + const ownerKey = args.ownerKey.trim(); + if (!ownerKey.length) { + throw new Error("ownerKey is required for dedicated OpenCode servers."); + } + const configFingerprint = serializeConfigFingerprint(args.config); + return await withAcquireLock(`dedicated:${ownerKey}`, async () => { + while (true) { + const existing = dedicatedEntries.get(ownerKey); + if (existing && existing.configFingerprint === configFingerprint) { + existing.refCount += 1; + existing.lastUsedAt = Date.now(); + logRuntimeEvent(args.logger, "opencode.server_reused", existing, { refCount: existing.refCount }); + return buildLease(existing, args.logger); + } + if (existing && existing.refCount > 0) { + existing.lastUsedAt = Date.now(); + logRuntimeEvent(args.logger, "opencode.server_config_mismatch_retained", existing, { + requestedConfigFingerprint: configFingerprint, + refCount: existing.refCount, + }); + existing.refCount += 1; + return buildLease(existing, args.logger); + } + if (existing) { + shutdownEntry(existing, "config_changed", args.logger); + } + enforceDedicatedBudget(args.logger, ownerKey); + const entry = await createEntry({ + key: ownerKey, + leaseKind: "dedicated", + ownerKind: args.ownerKind, + ownerId: args.ownerId, + config: args.config, + configFingerprint, + logger: args.logger, + }); + if (entry.configFingerprint !== configFingerprint) { + shutdownEntry(entry, "config_changed", args.logger); + continue; + } + entry.refCount = 1; + dedicatedEntries.set(ownerKey, entry); + return buildLease(entry, args.logger); + } + }); +} + +export function shutdownOpenCodeServers(filter: { + leaseKind?: OpenCodeServerLeaseKind; + ownerKind?: OpenCodeServerOwnerKind; + ownerId?: string | null; +} = {}): void { + const matches = (entry: OpenCodeServerEntry): boolean => { + if (filter.leaseKind && entry.leaseKind !== filter.leaseKind) return false; + if (filter.ownerKind && entry.ownerKind !== filter.ownerKind) return false; + if (filter.ownerId !== undefined && entry.ownerId !== (filter.ownerId?.trim() || null)) return false; + return true; + }; + for (const entry of [...sharedEntries.values(), ...dedicatedEntries.values()]) { + if (!matches(entry)) continue; + shutdownEntry(entry, "shutdown"); + } +} + +export function getOpenCodeRuntimeDiagnostics(): { + sharedCount: number; + dedicatedCount: number; + entries: OpenCodeRuntimeDiagnosticsEntry[]; +} { + const entries = [...sharedEntries.values(), ...dedicatedEntries.values()].map((entry) => ({ + id: entry.id, + key: entry.key, + leaseKind: entry.leaseKind, + ownerKind: entry.ownerKind, + ownerId: entry.ownerId, + configFingerprint: entry.configFingerprint, + url: entry.server.url, + busy: entry.busy, + refCount: entry.refCount, + startedAt: entry.startedAt, + lastUsedAt: entry.lastUsedAt, + })); + return { + sharedCount: sharedEntries.size, + dedicatedCount: dedicatedEntries.size, + entries, + }; +} + +export function __resetOpenCodeServerManagerForTests(): void { + shutdownOpenCodeServers(); + inFlightEntries.clear(); + acquireQueues.clear(); +} diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index d1bf89fc0..ef1f245e7 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -16,6 +16,14 @@ import { normalizeCoordinatorUpdateForChat, } from "./aiOrchestratorService"; +vi.mock("@opencode-ai/sdk", () => ({ + createOpencodeServer: vi.fn(async () => ({ + url: "http://mock-opencode-server", + close: vi.fn(), + })), + createOpencodeClient: vi.fn(() => ({})), +})); + function createLogger() { return { debug: () => {}, @@ -1724,6 +1732,75 @@ describe("aiOrchestratorService", () => { } }); + it("recreates the coordinator before resuming a paused run", async () => { + let capturedCoordinator: CoordinatorAgent | null = null; + const originalEnsurePlannerLaunchTrackerStep = (CoordinatorAgent.prototype as any).ensurePlannerLaunchTrackerStep; + const captureSpy = vi + .spyOn(CoordinatorAgent.prototype as any, "ensurePlannerLaunchTrackerStep") + .mockImplementation(function (this: CoordinatorAgent) { + capturedCoordinator = this; + return originalEnsurePlannerLaunchTrackerStep.call(this); + }); + const fixture = await createFixture(); + try { + const mission = fixture.missionService.create({ + prompt: "Resume cleanly after the coordinator is intentionally torn down.", + laneId: fixture.laneId, + }); + setMissionPlanningMode(fixture.db, mission.id, "auto"); + + const launched = await fixture.aiOrchestratorService.startMissionRun({ + missionId: mission.id, + runMode: "autopilot", + defaultExecutorKind: "opencode", + }); + const runId = launched.started?.run.id; + if (!runId) throw new Error("Expected mission run to start"); + expect(capturedCoordinator).toBeTruthy(); + + (capturedCoordinator as any)?.deps.onCoordinatorRuntimeFailure?.({ + category: "cli_runtime_failure", + reasonCode: "coordinator_runtime_cli_exit", + interventionType: "unrecoverable_error", + retryable: false, + recoveryOptions: ["retry", "cancel_run"], + message: "Codex CLI exited with code 1", + title: "Coordinator runtime exited unexpectedly", + body: "ADE paused the run because the coordinator process exited during execution. Error: Codex CLI exited with code 1.", + requestedAction: "Inspect coordinator runtime health, then resume the run to retry the same provider and mission state.", + turnId: "coord-turn-1", + }); + + await waitFor(() => fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 20 }).run.status === "paused"); + + fixture.aiOrchestratorService.resumeRun({ runId }); + await waitFor(() => fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 20 }).run.status === "active"); + + fixture.aiOrchestratorService.onOrchestratorRuntimeEvent({ + type: "orchestrator-run-updated", + runId, + at: new Date().toISOString(), + reason: "heartbeat", + } as any); + + const refreshedMission = fixture.missionService.get(mission.id); + expect(fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 20 }).run.status).toBe("active"); + expect( + refreshedMission?.interventions.some((entry) => + entry.status === "open" + && (String(entry.metadata?.reasonCode ?? "") === "coordinator_unavailable" + || String(entry.metadata?.reasonCode ?? "") === "coordinator_recovery_failed"), + ) ?? false, + ).toBe(false); + + const runView = await fixture.aiOrchestratorService.getRunView({ missionId: mission.id, runId }); + expect(runView?.coordinator.available).not.toBe(false); + } finally { + captureSpy.mockRestore(); + fixture.dispose(); + } + }); + it("persists a run-level autopilot cap from planner summary metadata in AI-first startup", async () => { const fixture = await createFixture(); try { @@ -2554,6 +2631,45 @@ describe("aiOrchestratorService", () => { } }); + it("shuts down the live coordinator when a hard cap pause is triggered", async () => { + let capturedCoordinator: CoordinatorAgent | null = null; + const originalEnsurePlannerLaunchTrackerStep = (CoordinatorAgent.prototype as any).ensurePlannerLaunchTrackerStep; + const captureSpy = vi + .spyOn(CoordinatorAgent.prototype as any, "ensurePlannerLaunchTrackerStep") + .mockImplementation(function (this: CoordinatorAgent) { + capturedCoordinator = this; + return originalEnsurePlannerLaunchTrackerStep.call(this); + }); + const shutdownSpy = vi.spyOn(CoordinatorAgent.prototype, "shutdown"); + const fixture = await createFixture(); + try { + const mission = fixture.missionService.create({ + prompt: "Pause this mission on budget hard cap.", + laneId: fixture.laneId, + }); + setMissionPlanningMode(fixture.db, mission.id, "auto"); + + const launched = await fixture.aiOrchestratorService.startMissionRun({ + missionId: mission.id, + runMode: "autopilot", + defaultExecutorKind: "opencode", + }); + const runId = launched.started?.run.id; + if (!runId) throw new Error("Expected mission run to start"); + expect(capturedCoordinator).toBeTruthy(); + + (capturedCoordinator as any)?.deps.onHardCapTriggered?.("Budget hard cap reached during planning."); + + expect(shutdownSpy).toHaveBeenCalled(); + const runGraph = fixture.orchestratorService.getRunGraph({ runId, timelineLimit: 10 }); + expect(runGraph.run.status).toBe("paused"); + } finally { + shutdownSpy.mockRestore(); + captureSpy.mockRestore(); + fixture.dispose(); + } + }); + it("cancels runs cleanly when both the coordinator and worker sessions are active", async () => { const sendMessage = vi.fn().mockResolvedValue(undefined); const interrupt = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index 2de4c02c6..ac57a58c6 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -5891,6 +5891,27 @@ Check all worker statuses and continue managing the mission from here. Read work metadata?: Record; }): void => { try { + const missionRuns = orchestratorService.listRuns({ missionId: args.missionId }); + for (const run of missionRuns) { + if (run.status === "active" || run.status === "bootstrapping" || run.status === "queued") { + try { + orchestratorService.pauseRun({ + runId: run.id, + reason: `${args.title}: ${args.body}`.slice(0, 400), + }); + } catch { + // ignore pause failures here; intervention creation still proceeds + } + const evalTimer = pendingCoordinatorEvals.get(run.id); + if (evalTimer) { + clearTimeout(evalTimer); + pendingCoordinatorEvals.delete(run.id); + } + } + if (run.status === "active" || run.status === "bootstrapping" || run.status === "queued" || run.status === "paused") { + endCoordinatorAgentV2(run.id); + } + } missionService.addIntervention({ missionId: args.missionId, interventionType: args.interventionType, @@ -6240,6 +6261,7 @@ Check all worker statuses and continue managing the mission from here. Read work clearTimeout(evalTimer); pendingCoordinatorEvals.delete(args.runId); } + endCoordinatorAgentV2(args.runId); } catch (error) { logger.debug("ai_orchestrator.pause_run_failed", { runId: args.runId, @@ -7148,6 +7170,56 @@ Check all worker statuses and continue managing the mission from here. Read work } }; + const ensureCoordinatorAgentV2 = (runId: string): { agent: CoordinatorAgent | null; created: boolean } => { + const existingAgent = coordinatorAgents.get(runId) ?? null; + if (existingAgent?.isAlive) { + return { agent: existingAgent, created: false }; + } + if (existingAgent && !existingAgent.isAlive) { + const recoveredAgent = attemptCoordinatorRecovery(runId); + return { agent: recoveredAgent, created: Boolean(recoveredAgent?.isAlive) }; + } + + const graph = getRunGraphSafe(runId); + const runStatus = graph?.run.status ?? null; + if (runStatus && TERMINAL_COORDINATOR_RUN_STATUSES.has(runStatus as OrchestratorRunStatus)) { + return { agent: null, created: false }; + } + + const missionId = graph?.run.missionId ?? getMissionIdForRun(runId); + if (!missionId) return { agent: null, created: false }; + + const mission = missionService.get(missionId); + if (!mission) return { agent: null, created: false }; + + const missionGoal = mission.prompt || mission.title; + const missionLaneId = resolvePersistedMissionLaneIdForRun(runId); + if (missionLaneId) { + persistMissionLaneIdForRun(runId, missionLaneId); + } + const coordinatorModelConfig = resolveOrchestratorModelConfig(missionId, "coordinator"); + const { userRules, projectCtx, availableProviders, phases } = gatherCoordinatorContext(missionId, { missionId }); + const agent = startCoordinatorAgentV2(missionId, runId, missionGoal, coordinatorModelConfig, { + userRules, + projectContext: projectCtx, + availableProviders, + phases, + skipInitialActivationMessage: true, + missionLaneId: missionLaneId ?? undefined, + }); + if (!agent?.isAlive) { + return { agent: null, created: false }; + } + + logger.info("ai_orchestrator.coordinator_resumed_after_pause", { + runId, + missionId, + runStatus, + }); + + return { agent, created: true }; + }; + const collectGracefulShutdownTargets = (runId: string): Array<{ sessionId: string; attemptId: string | null; @@ -7466,6 +7538,60 @@ Check all worker statuses and continue managing the mission from here. Read work return result; }; + const resumeRun = (args: { runId: string }): OrchestratorRun => { + const runId = toOptionalString(args.runId); + if (!runId) throw new Error("runId is required."); + + const currentRun = getRunGraphSafe(runId)?.run ?? null; + const shouldEnsureCoordinator = + currentRun?.status === "paused" + || currentRun?.status === "active" + || currentRun?.status === "bootstrapping" + || currentRun?.status === "queued"; + const ensuredCoordinator = shouldEnsureCoordinator ? ensureCoordinatorAgentV2(runId) : { agent: null, created: false }; + + if (shouldEnsureCoordinator && !ensuredCoordinator.agent?.isAlive) { + throw new Error("Coordinator runtime did not start successfully. Run remains paused."); + } + + try { + const resumedRun = orchestratorService.resumeRun({ runId }); + const coordinatorAgent = ensuredCoordinator.agent; + if (ensuredCoordinator.created && coordinatorAgent) { + const resumedGraph = getRunGraphSafe(runId); + const stepSummaries = resumedGraph?.steps.map((step) => ` - ${step.stepKey} (${step.title}): ${step.status}`).join("\n") ?? ""; + coordinatorAgent.injectMessage( + [ + "[RUN RESUMED]", + "You were restarted after this run was paused and the prior coordinator session was intentionally shut down.", + "Call read_mission_status immediately, review any open interventions or failed attempts, and continue from the current DAG state without redoing completed work.", + "", + "Current steps:", + stepSummaries || " (none)", + ].join("\n"), + ); + emitMilestoneReadinessToCoordinator({ runId, reason: "run_resumed" }); + + const missionId = resumedRun.missionId ?? currentRun?.missionId ?? getMissionIdForRun(runId); + if (missionId) { + emitCoordinatorLifecycle({ + missionId, + runId, + state: "booting", + message: "I’m back online and resuming the run.", + force: true, + }); + } + } + return resumedRun; + } catch (error) { + if (ensuredCoordinator.created) { + endCoordinatorAgentV2(runId); + } + throw error; + } + }; + const steerMission = (steerArgs: SteerMissionArgs): SteerMissionResult => { const missionId = steerArgs.missionId?.trim(); if (!missionId) throw new Error("missionId is required."); @@ -7748,7 +7874,7 @@ Check all worker statuses and continue managing the mission from here. Read work try { const runGraph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); if (runGraph.run.status === "paused") { - orchestratorService.resumeRun({ runId }); + resumeRun({ runId }); logger.info("ai_orchestrator.steer_auto_resumed_run", { missionId, runId }); } } catch (resumeError) { @@ -9530,6 +9656,7 @@ Check all worker statuses and continue managing the mission from here. Read work return { startMissionRun, + resumeRun, cancelRunGracefully, cleanupTeamResources, diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts index 147a1d015..8c55098cf 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { CoordinatorAgent } from "./coordinatorAgent"; +import { startOpenCodeSession } from "../opencode/openCodeRuntime"; const mockState = vi.hoisted(() => ({ eventBatches: [] as Array, @@ -24,6 +25,9 @@ vi.mock("../opencode/openCodeRuntime", () => ({ sessionId: "session-1", directory: args.directory, close: mockState.close, + touch: vi.fn(), + setBusy: vi.fn(), + setEvictionHandler: vi.fn(), })), openCodeEventStream: vi.fn(async () => ({ async *[Symbol.asyncIterator]() { @@ -329,4 +333,104 @@ describe("CoordinatorAgent", () => { agent.shutdown(); } }); + + it("releases idle coordinator sessions without shutting the agent down", async () => { + vi.useFakeTimers(); + mockState.eventBatches.push([ + { type: "session.idle", properties: { sessionID: "session-1" } }, + ]); + + const agent = createTestCoordinatorAgent() as any; + + try { + const initialStartCalls = vi.mocked(startOpenCodeSession).mock.calls.length; + agent.injectMessage("Start planning."); + await vi.advanceTimersByTimeAsync(250); + + const afterFirstBatchCalls = vi.mocked(startOpenCodeSession).mock.calls.length; + expect(afterFirstBatchCalls).toBeGreaterThan(initialStartCalls); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(mockState.close.mock.calls.length).toBeGreaterThan(0); + + expect(agent.isAlive).toBe(true); + } finally { + agent.shutdown(); + vi.useRealTimers(); + } + }); + + it("releases paused OpenCode coordinator sessions when the idle timer fires", async () => { + vi.useFakeTimers(); + const agent = createTestCoordinatorAgent({ + runStatus: "paused", + }) as any; + + try { + const handle = await agent.ensureOpenCodeCoordinatorSession(); + expect(handle.setEvictionHandler).toHaveBeenCalledWith(expect.any(Function)); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + + expect(handle.setEvictionHandler).toHaveBeenCalledWith(null); + expect(handle.setBusy).toHaveBeenCalledWith(false); + expect(handle.close).toHaveBeenCalledWith("paused_run"); + } finally { + agent.shutdown(); + vi.useRealTimers(); + } + }); + + it("keeps OpenCode eviction handlers bound to the handle instance that registered them", async () => { + const agent = createTestCoordinatorAgent() as any; + + try { + const firstHandle = await agent.ensureOpenCodeCoordinatorSession(); + const firstEvictionHandler = (firstHandle.setEvictionHandler as any).mock.calls[0]?.[0] as ((reason: string) => void) | undefined; + expect(firstEvictionHandler).toBeTypeOf("function"); + + (agent as any).releaseOpenCodeCoordinatorSession("handle_close"); + const secondHandle = await agent.ensureOpenCodeCoordinatorSession(); + const secondCloseCallCount = secondHandle.close.mock.calls.length; + + firstEvictionHandler?.("error"); + + expect(secondHandle.close.mock.calls.length).toBe(secondCloseCallCount); + expect(firstHandle.close).toHaveBeenCalledWith("handle_close"); + } finally { + agent.shutdown(); + } + }); + + it("uses project-root workspace binding for the OpenCode coordinator MCP launch", async () => { + mockState.eventBatches.push([ + { type: "session.idle", properties: { sessionID: "session-1" } }, + ]); + + const agent = createTestCoordinatorAgent({ + phases: createPlanningPhases(), + }) as any; + + try { + agent.injectMessage("Start planning."); + await agent.processBatch(); + + const startCalls = vi.mocked(startOpenCodeSession).mock.calls; + expect(startCalls.length).toBeGreaterThan(0); + expect(startCalls[0]?.[0]?.leaseKind).toBe("shared"); + const launch = startCalls[0]?.[0]?.dynamicMcpLaunch as { + cmdArgs?: string[]; + env?: Record; + }; + expect(Array.isArray(launch?.cmdArgs)).toBe(true); + const workspaceFlagIndex = launch.cmdArgs!.indexOf("--workspace-root"); + expect(workspaceFlagIndex).toBeGreaterThanOrEqual(0); + expect(launch.cmdArgs![workspaceFlagIndex + 1]).toBe("/tmp/ade-project"); + expect(launch.env?.ADE_WORKSPACE_ROOT).toBe("/tmp/ade-project"); + expect(launch.env?.ADE_RUN_ID).toBe("run-1"); + expect(launch.env?.ADE_MISSION_ID).toBeFalsy(); + } finally { + agent.shutdown(); + } + }); }); diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts index e5eea81e0..4dbdd1259 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -198,6 +198,7 @@ const MAX_EVENT_RETRY_COUNT = 2; const CHECKPOINT_TURN_INTERVAL = 5; const CHECKPOINT_SUMMARY_MAX_CHARS = 8_000; const COORDINATOR_TURN_TIMEOUT_MS = 120_000; +const COORDINATOR_IDLE_SESSION_TTL_MS = 5 * 60 * 1000; const PLANNING_STARTUP_RETRY_LIMIT = 1; const PLANNER_LAUNCH_TRACKER_STEP_KEY = "planner-launch-tracker"; const CHECKPOINT_DAG_MUTATION_TOOLS = new Set([ @@ -447,6 +448,7 @@ export class CoordinatorAgent { private lastEventTimestampMs: number | null = null; private activeAbortController: AbortController | null = null; private openCodeHandle: OpenCodeSessionHandle | null = null; + private openCodeIdleTimer: ReturnType | null = null; private planningStartupState: PlanningStartupState = "inactive"; private planningStartupRetryCount = 0; private plannerLaunchTrackerStepId: string | null = null; @@ -574,6 +576,7 @@ export class CoordinatorAgent { formattedMessage ?? formatRuntimeEvent(event).summary; this.lastEventTimestampMs = receivedAt; this.eventQueue.push({ message, receivedAt }); + this.touchOpenCodeCoordinatorSession(); this.scheduleBatch(); } @@ -585,6 +588,7 @@ export class CoordinatorAgent { const receivedAt = Date.now(); this.lastEventTimestampMs = receivedAt; this.eventQueue.push({ message, receivedAt }); + this.touchOpenCodeCoordinatorSession(); this.scheduleBatch(); } @@ -594,14 +598,55 @@ export class CoordinatorAgent { this.eventQueue = []; this.activeAbortController?.abort(); this.activeAbortController = null; - this.openCodeHandle?.close(); - this.openCodeHandle = null; + this.releaseOpenCodeCoordinatorSession("shutdown"); if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null; } } + private clearOpenCodeIdleTimer(): void { + if (!this.openCodeIdleTimer) return; + clearTimeout(this.openCodeIdleTimer); + this.openCodeIdleTimer = null; + } + + private releaseOpenCodeCoordinatorSession( + reason: "handle_close" | "idle_ttl" | "ended_session" | "model_switch" | "paused_run" | "project_close" | "budget_eviction" | "shutdown", + ): void { + this.clearOpenCodeIdleTimer(); + const handle = this.openCodeHandle; + this.openCodeHandle = null; + if (!handle) return; + handle.setEvictionHandler(null); + handle.setBusy(false); + try { + handle.close(reason); + } catch { + // ignore shutdown failures + } + } + + private touchOpenCodeCoordinatorSession(): void { + if (!this.openCodeHandle) return; + this.openCodeHandle.touch(); + this.clearOpenCodeIdleTimer(); + this.openCodeIdleTimer = setTimeout(() => { + if (this.dead) return; + if (!this.openCodeHandle) return; + if (this.isRunPaused()) { + this.releaseOpenCodeCoordinatorSession("paused_run"); + return; + } + if (this.activeAbortController || this.processing || this.eventQueue.length > 0) { + this.touchOpenCodeCoordinatorSession(); + return; + } + this.releaseOpenCodeCoordinatorSession("idle_ttl"); + }, COORDINATOR_IDLE_SESSION_TTL_MS); + if (this.openCodeIdleTimer.unref) this.openCodeIdleTimer.unref(); + } + get isAlive(): boolean { return !this.dead; } @@ -741,8 +786,8 @@ export class CoordinatorAgent { const mcpLaunch = resolveAdeMcpServerLaunch({ projectRoot: this.deps.projectRoot, workspaceRoot: this.deps.workspaceRoot, + workspaceBinding: "project_root", runtimeRoot: resolveOpenCodeRuntimeRoot(), - missionId: this.deps.missionId, runId: this.deps.runId, defaultRole: "orchestrator", }); @@ -765,9 +810,27 @@ export class CoordinatorAgent { directory: this.deps.workspaceRoot, title: `ADE coordinator: ${this.deps.missionGoal}`, projectConfig, - mcpLaunch, + dynamicMcpLaunch: mcpLaunch, discoveredLocalModels, + ownerKind: "coordinator", + ownerId: this.deps.runId, + ownerKey: `coordinator:${this.deps.runId}`, + leaseKind: "shared", + logger: this.deps.logger, + }); + const registeredHandle = this.openCodeHandle; + registeredHandle.setEvictionHandler((reason) => { + if (this.openCodeHandle !== registeredHandle) { + return; + } + if (this.openCodeHandle) { + this.releaseOpenCodeCoordinatorSession( + reason === "error" || reason === "config_changed" ? "handle_close" : reason, + ); + } }); + registeredHandle.setBusy(false); + this.touchOpenCodeCoordinatorSession(); return this.openCodeHandle; } @@ -787,6 +850,8 @@ export class CoordinatorAgent { throw new Error(`Coordinator model '${this.deps.modelId}' is not registered.`); } const handle = await this.ensureOpenCodeCoordinatorSession(); + handle.setBusy(true); + this.touchOpenCodeCoordinatorSession(); const eventStream = await openCodeEventStream({ client: handle.client, directory: handle.directory, @@ -806,6 +871,7 @@ export class CoordinatorAgent { body: { agent: mapPermissionModeToOpenCodeAgent("plan"), model: resolveOpenCodeModelSelection(descriptor), + ...(handle.toolSelection ? { tools: handle.toolSelection } : {}), parts: buildOpenCodePromptParts({ prompt: promptText, system: this.systemPrompt, @@ -993,7 +1059,7 @@ export class CoordinatorAgent { if (event.type === "todo.updated") { this.deps.onCoordinatorEvent?.({ type: "todo_update", - items: event.properties.todos.map((todo) => ({ + items: event.properties.todos.map((todo: { id: string; content: string; status: string }) => ({ id: todo.id, description: todo.content, status: todo.status === "completed" @@ -1779,6 +1845,8 @@ export class CoordinatorAgent { } throw error; } finally { + this.openCodeHandle?.setBusy(false); + this.touchOpenCodeCoordinatorSession(); if (this.activeAbortController === abortController) { this.activeAbortController = null; } diff --git a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts index 1f2a3b579..8c40a628a 100644 --- a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts @@ -59,6 +59,7 @@ function resolveWorkerOwnerId(metadata: Record | null | undefin export function resolveAdeMcpServerLaunch(args: { projectRoot: string; workspaceRoot: string; + workspaceBinding?: "explicit" | "project_root"; runtimeRoot: string; missionId?: string; runId?: string; diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts index 9ddf70d54..911fc215b 100644 --- a/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts +++ b/apps/desktop/src/main/services/runtime/adeMcpLaunch.test.ts @@ -82,6 +82,35 @@ describe("resolveDesktopAdeMcpLaunch", () => { }); }); + it("can bind workspace launch args to project root for shareable runtimes", () => { + const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-runtime-shared-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-shared-")); + const workspaceRoot = path.join(projectRoot, "workspace"); + const builtEntry = path.join(runtimeRoot, "apps", "mcp-server", "dist", "index.cjs"); + + fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.mkdirSync(path.dirname(builtEntry), { recursive: true }); + fs.writeFileSync(builtEntry, "module.exports = {};\n", "utf8"); + + const launch = resolveDesktopAdeMcpLaunch({ + projectRoot, + workspaceRoot, + workspaceBinding: "project_root", + runtimeRoot, + preferBundledProxy: false, + }); + + expect(launch.cmdArgs).toEqual([ + builtEntry, + "--project-root", + path.resolve(projectRoot), + "--workspace-root", + path.resolve(projectRoot), + ]); + expect(launch.env.ADE_PROJECT_ROOT).toBe(path.resolve(projectRoot)); + expect(launch.env.ADE_WORKSPACE_ROOT).toBe(path.resolve(projectRoot)); + }); + it("prefers the unpacked packaged proxy path over the asar path", () => { const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-resources-")); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-launch-project-")); diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts index 34da34d87..02982bc2c 100644 --- a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts +++ b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts @@ -4,6 +4,7 @@ import { resolveAdeLayout } from "../../../shared/adeLayout"; import type { ComputerUsePolicy } from "../../../shared/types"; export type AdeMcpLaunchMode = "bundled_proxy" | "headless_built" | "headless_source"; +export type AdeMcpWorkspaceBinding = "explicit" | "project_root"; export type AdeMcpLaunch = { mode: AdeMcpLaunchMode; @@ -20,6 +21,7 @@ export type AdeMcpLaunch = { export type DesktopAdeMcpLaunchArgs = { projectRoot: string; workspaceRoot: string; + workspaceBinding?: AdeMcpWorkspaceBinding; runtimeRoot?: string; missionId?: string; runId?: string; @@ -128,7 +130,10 @@ function buildLaunchEnv(args: { export function resolveDesktopAdeMcpLaunch(args: DesktopAdeMcpLaunchArgs): AdeMcpLaunch { const projectRoot = resolveRequiredRoot(args.projectRoot, "projectRoot"); - const workspaceRoot = resolveRequiredRoot(args.workspaceRoot, "workspaceRoot"); + const explicitWorkspaceRoot = resolveRequiredRoot(args.workspaceRoot, "workspaceRoot"); + const workspaceRoot = args.workspaceBinding === "project_root" + ? projectRoot + : explicitWorkspaceRoot; const socketPath = resolveAdeLayout(projectRoot).socketPath; const resourcesPath = resolveResourcesPath(); const env = buildLaunchEnv({ diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 60546a6e5..314fe27ec 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -124,6 +124,7 @@ import type { AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, + OpenCodeRuntimeSnapshot, SyncDesktopConnectionDraft, SyncDeviceRecord, SyncDeviceRuntimeState, @@ -615,6 +616,7 @@ declare global { }; ai: { getStatus: (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }) => Promise; + getOpenCodeRuntimeDiagnostics: () => Promise; storeApiKey: (provider: string, key: string) => Promise; deleteApiKey: (provider: string) => Promise; listApiKeys: () => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 5450159fa..0b8765945 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -35,6 +35,7 @@ import type { AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, + OpenCodeRuntimeSnapshot, SyncDesktopConnectionDraft, SyncDeviceRecord, SyncDeviceRuntimeState, @@ -655,6 +656,8 @@ contextBridge.exposeInMainWorld("ade", { ai: { getStatus: async (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }): Promise => ipcRenderer.invoke(IPC.aiGetStatus, args), + getOpenCodeRuntimeDiagnostics: async (): Promise => + ipcRenderer.invoke(IPC.aiGetOpenCodeRuntimeDiagnostics), storeApiKey: async (provider: string, key: string): Promise => ipcRenderer.invoke(IPC.aiStoreApiKey, { provider, key }), deleteApiKey: async (provider: string): Promise => diff --git a/apps/desktop/src/renderer/assets/AI Brain.lottie b/apps/desktop/src/renderer/assets/AI Brain.lottie new file mode 100644 index 0000000000000000000000000000000000000000..0f443acc7a74e3710a9e6fbcc804399c5d6841f4 GIT binary patch literal 7740 zcmbW6RZtv2x2^Hu?(Xgq+=IKj1()Ei!EG1_?(R+)+@0X=7Tg^YW{{izzTA6G)p!eP*B?#Nzgz3)t-Ms`=@Uf&h|Fe9-izD9xl#wGm_|o zl$h{yT~nQe#4VBJ{b~H1?DTmW^Y~NWi5DNQ!&q(a-)GIBdUCir_GnNm;7JFdHmHdf zzu{@QECD?moGLU-KrP>Pi`I>z5RN5sTQIl}QeE5!Evv$cOlHycShmBg+)$YW#oA*n zElB0uW|67H>n{yB-)sQ;Jjcy2q60i;k+3!YF= zy#KQQi~h~R)856|gM(Ah!b(8kGY^}!6~6!*uN5aho8V_o3pN{0K|wD5&lUoFg4X}{ zi&uF@iN>pLksruhv!V$eeYexHq*F7~3dUb|66>kUAA6p{5M8L_W4#BPN;NXwP*qsa!BudxIXwYas)izU$57kBpUAA17F;p zgvr5o$93;R{=R@C5b^8L$DQ1yRJ)5$ovD5MAhow%zNc15F8FD#e#kAiOM0l=(`BqK z{pf7vw*dbI)kj^MJ*!&OCNCGqaha;?oq~rk_`MOrq1M!ha^jbeky5c?fBgEqdsP*E z;&-0!HXl~HMw$()xp&zh7Fn+>smS>8L08UIT&1+Qx3Dzq+GTN?(Ily7OU-hg8=SmB zWX9ot_Xs&@QZ+c3HoNBR&*)YpPS0{rXJ5a^>u$Q*9EYvLQMUG_V84V$mWB_Da=I(x+OaFr#bi^Dcltnqcemq%36z}F%-mZ zW*C}r66x;EeAITkAQ5g0>m zvm}Jct?T zqGi$|WUdFYzdT|#579f^uZU+428*x<=O-S3NWtEzc<;IK98_=T`wi^xj_!AQkHIoG zADH!d7Xhp9ap^g;%k<5>V@*aDTLFOU5Gizdfoa__X^kSjq&I$vmYn4&OP+;jq>Pfc!SCIW3 z)K$|wlqBTJb8oo)9GNO~tep(Zt@q1K&?z7~xqEaBS-g$LaO$d#;XBWbKbi2-X)n86 zr;%Jd`l^nH!@6NdoOUbcs(XfeABWdPs?8uOZad0u+Z%oxT`+2JTKytC&n7(}B z{6+k9=1(i%GU=B0sQSpme_$aJ=IklIOVbRyTz}L!mMH*0jr}$`9=I{kdCYdI^7TS! zcP3Uci%Pf6m;8!#n`~~>*mS^GpP50(=;Hz*ugkajp8=jzxA7-bGK~g22`t)RqDoJEkp}ZKt+28q7=5sEg6-sB zx&cPeQ2v?thTo_+5lGY()m=-k(Gy-NghXG{oHhnV?RXfA8iq4MqA8V8@vz~ zs;I}F^h^&DXn}G7<*ZmbHhpotC5(oER!5<7%YeuZOv{FuM;~4}R~HqOErj+R==h?JJ7S8Z`88#^2b_hpFmQW^Al@p)T{ZYgVi{6>3Hao2NgB+#|!_$FNXLv!k=v$#nlB9^M-xu9g$-x=F#?@|qMY40J zQW9=@sQRyDmJt$T)XQrZJIf_`7Ox5kIGDg;fc1tLFUU1_WbU-?ic+!C!lhYJfxm|v zOHXNCD~vOnV`BlLGoQq69fNfI443-xW@16tWD2~e&|F@j2r-H^)>m}45m|)t%!{wg`(O@P?zTalQ3Hsu_&)+bKCnQSDewyz)-iQ`GO;lsL z{wTvVkl~D?Qel;c8!SJtI8U9H)yk|3*pJQSI=5!LDg0wDQ{g1R(1xIhJ?}&;n=`^l zH)$0nIQ88+Dn3d7QsvQbs^&7Hc{>~&N`!jcF^2R_Skj){K5r2w?S*CYOn+l1Iclf!ik2*?RKIOL zzCZE(OLpR)7MBB@RiY5grSxe4ad$7)7i!F9ZE*zq=#d0`!uYoE`BQ7%bbx4axo>jB z7*>Y`wAxOgV65$!03Xo-r{ynGT)Y6BRjZw77e@wfS_`C@N=xmi2eQ!vD{N(6bTQAb ziTI4M)@QB;*qCr{0=aCzBF~yl(YsjP%H0L(MpC)%uy#L7J4KE=1<0CUpoD+Ye^&Tm z1K8<*XjQYLtgqK<{n5yJWZY;+`S~VO@|YEoU-urZXgl8(+v5a|ZUDByVbb{+%UAwO z6YWHkII-x@yggH?K?`$?XcjgGXJH$s$X#y`dTi_Ed_^LXbNS?Ljad4srQ`XpOLEar zPv1tDbBAwPjvurR`z_s<>D(dotFhC^w&wXW>VfMG{P64{1f;li@NbupZH6NfM@j6VL$eWtCi4Qz*(DhUBF~C$4W?ZGW z+B=)2Eo3k>EXpWGP~rG6ZHqnfC5a9x=IxX;kDD>P$TSKFS#y+vs5y^Hfhy5_eh+m# zs!>A{){)t^E@<)@F*V`*3!lrt6_+Vn(Bolqn$&O8eqrEH7Hlv~R3S&@raq0HTLVM6x04OYC2dk<9` zjiC!IB-?{S@oA=vf&MwP>qXSgfvGV7Z?<`@DMeya_S<`M`6j+S48rNCrRo^5B9I~| zO3BL>Y3*ZP!_8$W#LJ$pDl-zJZJ?pDLOR3$edf_Hhm%RMhaGLjSeC0*_xmR*wR{$v zLr|cX(2*<+&o3tEpRz2wvCWvU5CEa=JamlfE#h<$5+00Va+P$WgN#yBjkS6LN136u zxfgK>>!)p@+aH~_9PE^u*5s!(gN8_mEoqSMSltri{ruNx&I5QA3HHs632!%UiD|wW zE0`vS@JnSK#NNZg4bZP5G&ImE@uHzmjWHlks~=yXiugySi*m;y=L-UJfZDBGhY5X}G0JX+ zAPo}SQ@%83IL&1w7GAXO6@oMYWjb5B0iM{%`x(_TH3vQB`(CnDR(<%IouD98IW^*X z?tP{q@ca8(^~R-SbPk#Ha3Jk%C(nHGc&et&tyrM^D$1#blO>ToEmXc{k|5Oii4@K_ zP~OmWi%2)IQ1fRdpKyt^`y5W8nj!%mMyAF+qv62V1Wg_@T4*=3Z;8gwuwNoDKXC~p zT9*w+e(G@Z3!se=I^lC7qmJDQXd}pFj}_4f`h>nI91@3nCyBqSXGE*A!00vnHL;GJ z;m;jUnqtoNyJ0rF0+BXkNaI&1{`4V@#uP&sH6&IOLiI4BR^QM%;-K@}??<8S z#FuS5_k_s*!iO2M_a}$ZnWOIGE~)8&4s82u+r|lal_vq< zg=@G8uFVQwmL@WSYfqe)@*{YxEDl^UlUJ5Lq+{N;*k-BR`_x=Yy$7XAi#x=w;&xF4 zZLx{iL4u@~>3hJ0!@+?74Fft@i6-t^eUJ@UB!-PL=dCamI{m|k⁡ms?T^+Ta|D{ zJ+@nRr7jlQm7|Ut>qbR&Lh23QXW}m^WD52!ofAiBNH$GlO442=(l0Z{u1B!#C0Pgv zZ*~G;_4dHWDYqoh=?bF%u6}1wAd<875x2qAL+SV8yJ-SzrVAYus~%aXVy=t3oZRw2 zl|9@rLZk=ujmLYw`_}J|##c?5cF~-PE|arhC5m)fM85et#93Wf0kDX^LOIp7ms{EC zBY4cr2}@RL#7IH1ek3H_+SLWueJm^cq8_!wqaiRc4#;|oanK2tzrcc zuQaTuQ~#lcGTb2E&TOX{*Ribk%WucbZ-m7YVrKPJ=b4wTO>-@*Q@EX9RCTSwd3s4P z<#b3Ba`lZ|gvY}VRla4M7$J|-z;lFxiF!+Fm!a`R%BCH2fZYtiV&&Aoh7>K`@=eeu zO$Nl6D!&b0Xzt5mjE8N1`}AR|OBu*puB3*;d-$w7Y>8#P?CD_xETiDuDIGUpL%E?s zN=r86BEl{2R(Xk^8;reux_6;r1mFZ|OJ))e@aG~1RjzfA+Xy!M%Ku3qmnDW70;J)z z;rOzOVe?BOk&VRUCw#))j+3%Y2<4^}ywoh#_1Jvbz3%D8b$As$Kl+>YPbep?=*%&sK zW_FFHcIlWJD7*OCzV>pMWYCGw^un zzSW2R#F;YOi5UisZBs6-F_O*rk<9&Ygza4QWht^S-cAV>zsE7GW^OT8KQr6ftjf@O zP)1bzU}o9ld3HO(O*ic$fh%gEo=`d%{S3x(eE#IypdCt;eix_dNwoe)z8C<5m;5^5 za_6RI^Us2)9qp^{qOo`$@iF`7AamY0cW3b5)3$@li`cmd&da!l;**k6$aC6VtCaM$ zjT$BGRtIQ~0gyIXk5T}Jbi~(ItP0TM4Jw7IUUPyy@X8U@%pWL1A{@E2{VPyYr zK3(i;A6ej@nxnUtB6L}o2FJ5Blq;r`xE#8HmnWLaCp83l>>&oyfZz45&QfveG!m^?Xi01QC zH%o5K?dhkxX`B;AtXt21;>K+T$y^W#TGhDduO^{L3uQpyn`)+5(9%Gp5cRUQ-~}3P z(>z4mC`^JB-LSm1k(B)Sps%e9n%4CS&e|SKsd?PI3TIAng*w)38Ku1h=tKn|9QpHO z;vktcsR1~x;;dYQ$aCCm@D8j*w!t}MA7kW-(9^q%Q8#EM4M>rlIemR&%b=h%XZ!iC z*U^oyXI${>iiQHrvQOIKw}z0#4&JvsKm5h%04B$u)E70mbT%EP<)$RXJS#gD3R5%a zvsNI%bRH>F@p9|)UTt>~pGM!hQ2DKAI9rk#1J9y^T_09k{7P_8TKjPwcAh}lpryjH zHyzOKDTPHaUBgVxzTV-2m#zgLxg{eyRC-!ZC(gE0RZ&x(RR?RBZeTAW9_xv2{eEJD z8k(Sll{^YJwPx{;r=p$I+a0q*(|ZAo zJjajXP@Ae^5vEw$L16qmt@R3X`fq0t?v)?=V-t{Te4A@+$#%JZDQ2vMkAT@CN;< zM6!Z_pbAF~n$&X*AaY%i=QFhd>}Oh>Tk;i(zw8E5g4(<3Xahv_-u>s_5bFQw>$j~!g`ZFb*z_xR5O6TdSXBpoT@Kg{rV}^<>?90yjr$Y$kQ92svRR6Q<0=^UzKOa=QU&1jF2k?5;vV+UHhqx zni2(K-`L=N7AB;`(=cm4!C54ae`7-|Pxa2)R4z;p_es^vniUC{D-cRq@4>B{?f;&F z;k6TCd&tq^&a)uBvXJNg@`ZW0=NzX!{a&{olQtHNP1huXt6do z)QIx3;;ySYHq!`v&r~=P-XK4uX0eN+Z~qWv@YcFjcVkuG0=kOde)b9i&T3|-=D=Go zcpu=~LFbX37Kg%Cy5>iezE-ypxl1C1y#UIJG0RFO*i@^+dZpaGtT7~G(ZF=s%^u+@ zbVOo~ZsnZtO*9duX-94LFi5fKTT)yBiQReCx7A?D!ksMHmn(daeb9NTRFa&ESs8@# z=$EukCDo zod4Q|EZbEzYo6`N_3=td;0XDi1a)ta|K19w&*w|cFHhH{I3X3N{EHo%Z?z08eU0jr zNPVEaPUWkLxkR|7Q2xKHcpVi-TaWJEj4E{RtTKi|lU_Ag89mH{TRrCRi;J7IDIA%T z^d~Ze+Lx*QHD&BtLL<^yMzFoP+g0^QqNag4{`IY@_IYg-O!O%wK?Abld6d#DSNcae zX+bmLr4qz3)Fjpkj(YPo&!1~rZ9gIuI;eWMq36MukyO5ffz^{xn0#1*COgQ8*5 zLyWCvu?xN4+B~Vw$xi4T|D1P_omx%D((-iahnBu#$7cu%V65%`Sf}~Bn#*Ia4$FI+ zU?#%n|0l7e5)ZFLFQjq+bSu0S9@jYoAG1~5TWL$jZ!XS5;&t}d5BTKQ*hx^UYogZ$ zJYP>n6rV1?CRjW&L^YwLb#`^Od6ktA-NWC?yW&O6T68!xvr#B6>7*Wu7ewd7pqqzA zI)Y8d=dZPVz}`dF$p^)ltS7nKGTOa4OjOoa;WFur*%pLZq0|=DMYg!-0*U#X8{wC1 z(k|EfcwpePn2n5>XxR~zkIV7V26IX=k)irwavFt;5?v@q4K)3Q#tpSmWUw=Vs(^Fj z6B^PWo*${C>&;!5h8B24(`fq3106%38bYycAUt`5Yh{X_3mWCZb`@?Inx`;yZta+= z5v1n&$^=+xrUG;g)fvncOI$~a1O+;+f0{1GgRo_AN0PTDQ}x!;JrOLKm16liT=Qla z7_5<@xGNQ{#wE5faYohuoZ_!*AD6d&33Ah^Q=FD@g8p+wo3uRv0{yZ!@>3i=3Z60i zw)zm5vCnhng~zw>60Xl@TtR?=qeZ%ybq|jvOW%KTv@AxGQDJTOVyaS3f7-L2og;f_ z%I}!cRE1z#r!q?4y1im!ncieiT07lJV=284Afek>@eCKL?IxQm>dXq1YHn+?{>8*L zzVV1`kdtChL{gK|vaUp6nLIlu HKezt_>EFMc literal 0 HcmV?d00001 diff --git a/apps/desktop/src/renderer/assets/brain-thinking.json b/apps/desktop/src/renderer/assets/brain-thinking.json new file mode 100644 index 000000000..529723df9 --- /dev/null +++ b/apps/desktop/src/renderer/assets/brain-thinking.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE 3.5.2","a":"","k":"","d":"","tc":""},"fr":29.9700012207031,"ip":0,"op":76.0000030955435,"w":500,"h":500,"nm":"Machinery Brain","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"circle 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[263.639,194.572,0],"ix":2},"a":{"a":0,"k":[4.709,4.709,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":15,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":25,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":50,"s":[100,100,100]},{"t":60.0000024438501,"s":[0,0,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.458,0],[0,2.458],[-2.458,0],[0,-2.459]],"o":[[-2.458,0],[0,-2.459],[2.458,0],[0,2.458]],"v":[[0,4.458],[-4.459,0.001],[0,-4.458],[4.459,0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.187999994615,0.231000010173,0.438999998803,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[4.709,4.708],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.0000048877,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"circle 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[297.71,224.464,0],"ix":2},"a":{"a":0,"k":[4.709,4.708,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":15,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":25,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":50,"s":[100,100,100]},{"t":60.0000024438501,"s":[0,0,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.458,0],[0,2.458],[-2.458,0],[0,-2.459]],"o":[[-2.458,0],[0,-2.459],[2.458,0],[0,2.458]],"v":[[0,4.458],[-4.459,0.001],[0,-4.458],[4.459,0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.187999994615,0.231000010173,0.438999998803,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[4.709,4.709],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.0000048877,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"circle 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[263.691,241.132,0],"ix":2},"a":{"a":0,"k":[4.709,4.708,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":15,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":25,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":50,"s":[100,100,100]},{"t":60.0000024438501,"s":[0,0,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.458,0],[0,2.458],[-2.458,0],[0,-2.459]],"o":[[-2.458,0],[0,-2.459],[2.458,0],[0,2.458]],"v":[[0,4.458],[-4.458,0.001],[0,-4.458],[4.458,0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.187999994615,0.231000010173,0.438999998803,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[4.708,4.708],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.0000048877,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"circle 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[290.882,260.149,0],"ix":2},"a":{"a":0,"k":[4.709,4.709,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":15,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":25,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":50,"s":[100,100,100]},{"t":60.0000024438501,"s":[0,0,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.458,0],[0,2.458],[-2.458,0],[0,-2.459]],"o":[[-2.458,0],[0,-2.459],[2.458,0],[0,2.458]],"v":[[0,4.458],[-4.458,0.001],[0,-4.458],[4.458,0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.187999994615,0.231000010173,0.438999998803,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[4.708,4.709],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.0000048877,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"circle 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[297.382,294.069,0],"ix":2},"a":{"a":0,"k":[4.709,4.708,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":15,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":25,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":50,"s":[100,100,100]},{"t":60.0000024438501,"s":[0,0,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.458,0],[0,2.458],[-2.458,0],[0,-2.459]],"o":[[-2.458,0],[0,-2.459],[2.458,0],[0,2.458]],"v":[[0.001,4.458],[-4.458,0.001],[0.001,-4.458],[4.458,0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.187999994615,0.231000010173,0.438999998803,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[4.708,4.708],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.0000048877,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"circle 6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[277.568,315.187,0],"ix":2},"a":{"a":0,"k":[4.708,4.709,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":15,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":25,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":50,"s":[100,100,100]},{"t":60.0000024438501,"s":[0,0,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.458,0],[0,2.458],[-2.458,0],[0,-2.459]],"o":[[-2.458,0],[0,-2.459],[2.458,0],[0,2.458]],"v":[[0,4.458],[-4.458,0.001],[0,-4.458],[4.458,0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.187999994615,0.231000010173,0.438999998803,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[4.708,4.709],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.0000048877,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"line 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[280.529,243.232,0],"ix":2},"a":{"a":0,"k":[19.349,19.214,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":0,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":15,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":60,"s":[100,100,100]},{"t":75.0000030548126,"s":[0,0,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.819,2.076],[0.171,0.03],[0,0],[2.442,1.457],[1.11,0.955],[-0.698,0.861],[-0.524,0],[-0.421,-0.3],[-2.954,-0.764],[-1.438,0],[-2.296,7.263],[-0.066,1.685],[-1.098,0],[0.016,-1.595],[4.496,-0.882],[0.069,-0.138],[-0.066,-0.139],[-5.836,-0.814],[-0.223,-0.01],[-0.12,-0.019],[0.128,-0.993],[1.212,-0.003]],"o":[[-10.19,-0.976],[-0.064,-0.163],[0,0],[-3.175,-0.535],[-1.066,-0.64],[-0.927,-0.793],[0.379,-0.471],[0.415,0],[2.726,1.941],[1.366,0.353],[7.851,0],[0.667,-2.127],[0.053,-1.403],[1.252,0.04],[-0.884,16.139],[-0.151,0.03],[-0.069,0.138],[1.237,2.582],[0.218,0.028],[0.233,0.011],[1.211,0.206],[-0.139,1.077],[0,0]],"v":[[9.908,18.962],[-5.705,5.501],[-6.084,5.191],[-6.321,5.15],[-14.785,2.148],[-18.019,-0.221],[-18.401,-2.986],[-17.003,-3.715],[-15.724,-3.257],[-7.623,0.896],[-3.397,1.428],[14.115,-11.105],[15.174,-16.617],[17.101,-18.964],[19.083,-16.352],[-1.03,4.803],[-1.38,5.07],[-1.384,5.51],[9.197,14.997],[9.86,15.048],[10.336,15.085],[12.236,17.187],[10.012,18.964]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.187999994615,0.231000010173,0.438999998803,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[19.349,19.214],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.0000048877,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"line 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[288.199,304.934,0],"ix":2},"a":{"a":0,"k":[12.412,12.911,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":0,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":15,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":60,"s":[100,100,100]},{"t":75.0000030548126,"s":[0,0,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-0.052,1.675],[-11.196,1.489],[-0.471,0],[-0.232,-0.043],[0.007,-0.907],[0.996,-0.232],[0.486,-0.03],[0.645,-0.143],[2.726,-6.862],[0.059,-2.012],[1.133,-0.008]],"o":[[-1.211,-0.061],[0.353,-11.29],[0.524,-0.069],[0.278,0],[1.016,0.194],[-0.01,0.881],[-0.411,0.095],[-0.585,0.033],[-6.75,1.485],[-0.765,1.931],[-0.052,1.628],[0,0]],"v":[[-10.252,12.657],[-12.109,9.884],[8.151,-12.53],[9.656,-12.661],[10.413,-12.598],[12.155,-10.697],[10.474,-8.835],[9.118,-8.682],[7.286,-8.471],[-6.996,4.108],[-8.238,10.05],[-10.13,12.661]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.187999994615,0.231000010173,0.438999998803,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[12.411,12.911],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.0000048877,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"brain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[286.539,249.676,0],"ix":2},"a":{"a":0,"k":[41.935,86.981,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[3.776,-5.837],[1.3,-1.023],[-0.346,-1.978],[9.088,-2.78],[1.342,0],[0.554,0.069],[0.221,0],[0.719,-1.397],[5.035,-1.95],[2.642,0],[4.219,6.031],[-2.158,6.653],[-6.473,2.268],[-2.518,0.125],[-0.429,0.719],[0.318,0.678],[1.203,0],[0,0],[-0.042,0.526],[-0.014,20.941],[-4.731,0.567],[-0.387,0.705],[0.29,0.678],[1.134,0],[0,0],[0,0],[-0.056,6.418],[-7.87,2.172],[-1.369,0],[-2.642,-5.132],[0,0],[0,0],[2.476,-1.66],[0.899,-0.858],[-0.774,-0.955],[-0.622,0],[-0.456,0.387],[-3.071,0.402],[-0.581,0],[-2.863,-3.112],[0.291,-3.61],[0,0],[6.189,-20.691],[0.027,-1.549],[-1.466,-0.083],[-0.069,1.494],[-3.057,3.513],[-4.993,0],[-3.513,-4.371],[3.61,-7.289],[-2.199,-1.632],[0.387,-6.543],[9.102,-0.387],[0.443,-0.692],[-0.29,-0.706],[-1.3,0],[0,0],[0,0],[0,0]],"o":[[-0.761,1.176],[-1.328,1.038],[1.715,9.337],[-1.48,0.456],[-0.567,0],[-0.235,-0.041],[-1.259,0],[-2.462,4.855],[-2.559,0.996],[-6.847,0],[-4.094,-5.851],[1.489,-4.714],[1.951,-0.692],[1.051,-0.041],[0.346,-0.567],[-0.443,-0.912],[0,0],[-14.27,1.874],[-0.014,-20.928],[0,-4.662],[1.093,-0.125],[0.318,-0.54],[-0.374,-0.857],[0,0],[-3.306,0.097],[0,0],[0.083,-8.244],[1.259,-0.345],[6.169,0],[0,0],[0,0],[-2.186,0.512],[-1.023,0.678],[-0.968,0.94],[0.429,0.525],[0.526,0],[3.057,-2.531],[0.567,-0.069],[4.08,0],[2.656,2.877],[0,0],[0,0],[-0.387,1.646],[-0.042,1.618],[1.217,0],[0.208,-4.759],[3.623,-4.15],[5.132,0],[5.27,6.57],[-0.899,1.812],[5.436,4.025],[-0.595,9.544],[-1.134,0.042],[-0.36,0.553],[0.388,0.94],[0,0],[7.189,-0.649],[0,0],[3.735,5.131]],"v":[[32.587,37.886],[29.613,41.413],[28.258,45.604],[15.103,67.21],[10.912,67.888],[9.238,67.777],[8.547,67.722],[5.725,69.714],[-5.576,79.963],[-13.419,81.471],[-30.833,71.982],[-34.219,49.965],[-20.418,38.495],[-13.778,37.291],[-11.413,36.074],[-11.385,34.137],[-13.764,32.823],[-14.013,32.823],[-34.941,42.438],[-34.941,-23.459],[-27.375,-31.8],[-25.024,-33.1],[-24.996,-34.954],[-27.347,-36.282],[-27.555,-36.282],[-34.941,-33.392],[-34.927,-62.687],[-20.985,-80.946],[-17.015,-81.471],[-2.062,-72.757],[-1.426,-71.498],[-2.795,-71.18],[-10.279,-68.206],[-13.17,-65.882],[-13.488,-62.77],[-11.842,-61.954],[-10.348,-62.535],[-1.481,-66.768],[0.248,-66.864],[11.189,-61.982],[14.868,-51.94],[14.708,-50.214],[-11.565,-32.644],[-12.187,-27.83],[-9.919,-25.036],[-7.72,-27.581],[-2.795,-40.044],[10.566,-46.476],[23.983,-39.698],[26.487,-18.826],[27.801,-14.745],[35.395,1.176],[17.828,19.185],[15.352,20.347],[15.255,22.27],[17.759,23.667],[17.939,23.667],[32.158,18.314],[32.642,18.964]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4,0.475,0.838999968884,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[40.34,86.912],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-0.567,-1.169],[0.47,-0.77],[1.307,-0.052],[1.859,-0.66],[2.018,-6.28],[-3.964,-5.664],[-6.601,0],[-2.484,0.967],[-2.374,4.679],[-1.537,0],[-0.254,-0.045],[-0.5,0],[-1.407,0.434],[1.647,8.966],[-1.536,1.199],[-0.723,1.118],[3.563,4.896],[0,0],[0,0],[0.525,-0.381],[0.231,-0.149],[3.923,-0.144],[0,0],[0.509,1.235],[-0.489,0.752],[-1.377,0.05],[-0.571,9.172],[5.211,3.857],[-1.13,2.275],[5.07,6.321],[4.907,0],[3.477,-3.983],[0.2,-4.579],[1.622,0],[-0.051,2.058],[-0.396,1.688],[-10.27,0],[-0.905,-0.144],[0,0],[-0.074,0],[-0.176,0.229],[0,0],[0,0],[2.519,2.728],[3.89,0],[0.505,-0.061],[2.933,-2.429],[0.709,0],[0.567,0.693],[-1.245,1.209],[-1.025,0.679],[-2.243,0.525],[0,0],[0,0],[5.894,0],[1.205,-0.331],[0.08,-7.916],[-0.014,-6.288],[0,0],[0,0],[-0.435,-0.522],[0,0],[0,0],[-1.936,0.089],[0,0],[-0.493,-1.13],[0.437,-0.742],[1.356,-0.154],[0,-4.302],[-0.015,-20.928],[-7.107,0.328],[-0.012,0]],"o":[[1.488,0],[0.432,0.92],[-0.554,0.932],[-2.457,0.121],[-6.251,2.19],[-2.084,6.425],[4.075,5.827],[2.541,0],[4.862,-1.883],[0.844,-1.64],[0.298,0],[0.527,0.065],[1.265,0],[8.734,-2.672],[-0.393,-2.25],[1.21,-0.953],[3.607,-5.578],[0,0],[0,0],[-0.675,0.225],[-0.29,0.218],[-3.603,2.274],[0,0],[-1.613,0],[-0.384,-0.936],[0.577,-0.903],[8.715,-0.37],[0.371,-6.275],[-2.366,-1.757],[3.465,-6.997],[-3.368,-4.189],[-4.775,0],[-2.945,3.384],[-0.087,1.892],[-1.902,-0.106],[0.029,-1.606],[2.527,-10.762],[0.944,0],[0,0],[0.071,0.009],[0.323,0],[0,0],[0,0],[0.272,-3.379],[-2.714,-2.95],[-0.597,0],[-2.928,0.382],[-0.578,0.49],[-0.849,0],[-1.021,-1.259],[0.967,-0.923],[2.58,-1.729],[0,0],[0,0],[-2.515,-4.887],[-1.298,0],[-7.555,2.085],[-0.056,6.458],[0,0],[0,0],[-0.022,0.375],[0,0],[0,0],[1.909,-0.983],[0,0],[1.439,0],[0.393,0.919],[-0.491,0.896],[-4.384,0.526],[-0.015,20.942],[-0.045,0.582],[0.012,0],[0,0]],"v":[[-15.359,32.004],[-12.305,33.741],[-12.368,36.396],[-15.344,37.972],[-21.761,39.133],[-34.771,52.46],[-31.813,71.483],[-15.014,80.651],[-7.443,79.195],[3.462,69.305],[6.952,66.902],[7.773,66.969],[9.317,67.068],[13.288,66.424],[25.925,45.671],[27.556,40.753],[30.362,37.41],[30.44,19.336],[30.289,19.133],[30.048,19.217],[28.304,20.217],[27.553,20.756],[16.371,24.347],[16.164,24.348],[12.966,22.486],[13.129,19.869],[16.206,18.366],[33.051,1.06],[25.759,-14.211],[24.22,-19.228],[21.803,-39.298],[8.971,-45.795],[-3.825,-39.619],[-8.565,-27.618],[-11.514,-24.355],[-14.533,-27.918],[-13.891,-32.885],[8.556,-51.763],[11.343,-51.546],[11.448,-51.534],[11.668,-51.514],[12.442,-51.87],[12.516,-51.965],[12.526,-52.07],[9.042,-61.542],[-1.348,-66.183],[-2.986,-66.092],[-11.464,-62.026],[-13.436,-61.273],[-15.664,-62.364],[-15.287,-66.489],[-12.288,-68.901],[-4.561,-71.979],[-4.122,-72.082],[-4.327,-72.487],[-18.61,-80.79],[-22.381,-80.291],[-35.772,-62.749],[-35.8,-43.503],[-35.785,-37.21],[-35.803,-36.968],[-35.42,-35.586],[-35.232,-35.356],[-34.981,-35.484],[-29.184,-37.1],[-28.942,-37.101],[-25.903,-35.323],[-25.973,-32.788],[-28.885,-31.124],[-35.785,-23.528],[-35.785,39.283],[-15.643,32.005],[-15.608,32.004]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0.099,0.045],[0.597,0.038],[0.371,0.084],[4.345,6.899],[0,0.387],[0,0],[-0.091,0.181],[0,0],[-7.877,0],[-0.717,-0.058],[-4.635,-9.142],[0,0],[0,0],[0.271,-11.194],[0,0],[0,0],[3.935,-13.329],[0,0],[0,0],[6.809,-12.596],[0,0],[0,0],[8.318,-9.528],[0,0],[0,0],[4.639,-5.639],[7.32,-0.186],[0,0],[0,0],[0.407,-0.584],[0.786,-0.858],[6.574,-0.839],[0,0],[0.291,-0.156],[0.124,0]],"o":[[-0.109,0],[-0.583,-0.269],[-0.351,-0.016],[-7.741,-1.645],[-4.079,-5.902],[0,0],[0,-4.077],[0,0],[5.376,-7.994],[0.672,0],[8.695,0.729],[0,0],[0,0],[10.655,3.57],[0,0],[0,0],[11.901,7.599],[0,0],[0,0],[9.554,9.226],[0,0],[0,0],[6.445,10.822],[0,0],[0,0],[0.998,7.08],[-4.609,5.595],[0,0],[0,0],[-0.438,0.562],[-0.65,0.934],[-4.552,4.937],[0,0],[-0.233,0.021],[-0.109,0.059],[0,0]],"v":[[-16.411,86.731],[-16.725,86.662],[-18.521,86.316],[-19.593,86.2],[-37.559,73.502],[-41.685,58.036],[-41.685,-64.879],[-39.461,-73.413],[-38.499,-74.85],[-18.804,-86.731],[-16.738,-86.645],[3.07,-71.975],[3.135,-71.839],[3.261,-71.799],[18.685,-49.86],[18.68,-49.655],[18.859,-49.537],[30.702,-18.433],[30.64,-18.228],[30.796,-18.077],[34.876,14.355],[34.778,14.53],[34.882,14.698],[32.097,44.936],[31.989,45.061],[32.012,45.225],[26.523,64.395],[8.547,73.106],[8.381,73.11],[8.28,73.241],[7.028,74.957],[4.919,77.717],[-11.848,86.42],[-12.064,86.438],[-12.889,86.642],[-13.243,86.731]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.187999994615,0.231000010173,0.438999998803,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[41.935,86.981],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.0000048877,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"machine","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":75.0000030548126,"s":[360]}],"ix":10},"p":{"a":0,"k":[241.079,249.69,0],"ix":2},"a":{"a":0,"k":[74.624,75.946,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.597,-18.286],[17.879,0.122],[0.602,18.334],[-17.879,-0.123]],"o":[[0.61,18.314],[-17.898,-0.131],[-0.605,-18.265],[17.891,0.15]],"v":[[32.45,0.223],[1.085,33.176],[-32.462,-0.271],[-1.085,-33.195]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.323,1.164],[0,0],[1.46,0.011],[0,0],[0.308,-1.445],[0,0],[1.121,-0.306],[3.783,-2.233],[1.023,0.621],[0,0],[0.998,-1.049],[0,0],[-0.792,-1.285],[0,0],[0.546,-1.032],[1.052,-4.459],[1.125,-0.283],[0,0],[-0.048,-1.492],[0,0],[-1.429,-0.373],[0,0],[-0.345,-1.153],[-2.341,-3.958],[0.568,-1.021],[0,0],[-1.067,-1.063],[0,0],[-1.228,0.758],[0,0],[-1.033,-0.602],[-4.43,-1.249],[-0.322,-1.165],[0,0],[-1.461,-0.011],[0,0],[-0.308,1.445],[0,0],[-1.121,0.305],[-3.779,2.221],[-1.021,-0.62],[0,0],[-0.999,1.047],[0,0],[0.792,1.286],[0,0],[-0.546,1.033],[-1.054,4.463],[-1.125,0.282],[0,0],[0.048,1.491],[0,0],[1.431,0.373],[0,0],[0.345,1.159],[2.344,3.945],[-0.566,1.018],[0,0],[1.069,1.063],[0,0],[1.227,-0.757],[0,0],[1.034,0.599],[4.43,1.251]],"o":[[0,0],[-0.402,-1.45],[0,0],[-1.46,-0.011],[0,0],[-0.248,1.16],[-4.341,1.184],[-0.994,0.587],[0,0],[-1.279,-0.777],[0,0],[-0.997,1.047],[0,0],[0.634,1.029],[-2.074,3.926],[-0.271,1.15],[0,0],[-1.404,0.352],[0,0],[0.048,1.493],[0,0],[1.142,0.299],[1.346,4.491],[0.616,1.042],[0,0],[-0.709,1.274],[0,0],[1.068,1.064],[0,0],[0.982,-0.606],[3.928,2.291],[1.143,0.322],[0,0],[0.402,1.451],[0,0],[1.46,0.011],[0,0],[0.248,-1.16],[4.341,-1.184],[0.993,-0.583],[0,0],[1.278,0.776],[0,0],[0.999,-1.047],[0,0],[-0.634,-1.029],[2.075,-3.929],[0.271,-1.15],[0,0],[1.404,-0.352],[0,0],[-0.048,-1.494],[0,0],[-1.149,-0.299],[-1.341,-4.502],[-0.616,-1.037],[0,0],[0.709,-1.275],[0,0],[-1.067,-1.061],[0,0],[-0.983,0.606],[-3.931,-2.282],[-1.142,-0.323]],"v":[[9.922,-54.784],[6.53,-66.995],[3.361,-69.48],[-7.893,-69.564],[-10.902,-67.124],[-13.499,-54.954],[-15.732,-52.608],[-27.956,-47.439],[-31.215,-47.464],[-42.007,-54.015],[-45.882,-53.551],[-53.571,-45.466],[-53.92,-41.499],[-47.246,-30.664],[-47.132,-27.336],[-51.857,-14.718],[-54.09,-12.383],[-65.928,-9.41],[-68.235,-6.273],[-67.867,5.243],[-65.354,8.419],[-53.311,11.568],[-50.93,13.933],[-45.37,26.641],[-45.263,29.974],[-51.24,40.719],[-50.63,44.695],[-42.4,52.895],[-38.493,53.414],[-28.139,47.017],[-24.883,47.04],[-12.311,52.385],[-9.923,54.767],[-6.536,66.993],[-3.366,69.48],[7.886,69.564],[10.895,67.125],[13.499,54.934],[15.732,52.589],[27.951,47.433],[31.203,47.462],[41.983,54.008],[45.856,53.547],[53.56,45.467],[53.912,41.497],[47.239,30.665],[47.125,27.335],[51.857,14.698],[54.09,12.364],[65.928,9.39],[68.235,6.254],[67.867,-5.26],[65.35,-8.437],[53.316,-11.567],[50.923,-13.944],[45.364,-26.649],[45.253,-29.969],[51.239,-40.736],[50.627,-44.715],[42.391,-52.9],[38.489,-53.417],[28.139,-47.035],[24.881,-47.054],[12.309,-52.404]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4,0.475,0.838999968884,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[74.623,75.945],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-14.39,-0.104],[-4.844,5.084],[0.24,7.225],[0,0],[14.392,0.122],[-0.493,-14.919]],"o":[[0.704,14.724],[7.06,0.049],[4.847,-5.087],[0,0],[-0.698,-14.676],[-14.613,-0.1],[0,0]],"v":[[-26.486,0.195],[0.885,27.085],[19.345,19.277],[26.49,0.183],[26.473,-0.257],[-0.894,-27.107],[-26.503,-0.231]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[2.323,-0.584],[0,0],[0.111,-0.403],[1.513,-3.15],[-0.01,-0.201],[-0.101,-0.165],[0,0],[-0.045,-0.944],[0.968,-1.015],[0,0],[2.115,1.286],[0,0],[0.362,-0.194],[3.278,-1.035],[0.08,-0.378],[0,0],[2.417,0.02],[0,0],[0.665,2.402],[0,0],[0.406,0.131],[3.143,1.677],[0.325,-0.201],[0,0],[1.767,1.761],[0,0],[0.066,1.406],[-0.476,0.855],[0,0],[0.009,0.205],[0.096,0.176],[1.158,3.412],[0.374,0.097],[0,0],[0.116,2.43],[0,0],[-2.323,0.585],[0,0],[-0.111,0.403],[-1.51,3.143],[0.01,0.202],[0.101,0.165],[0,0],[0.045,0.943],[-0.965,1.015],[0,0],[-2.117,-1.285],[0,0],[-0.365,0.197],[-3.283,1.038],[-0.082,0.382],[0,0],[-2.418,-0.02],[0,0],[-0.667,-2.4],[0,0],[-0.4,-0.13],[-3.163,-1.685],[-0.321,0.197],[0,0],[-1.767,-1.755],[0,0],[-0.067,-1.409],[0.476,-0.856],[0,0],[-0.01,-0.207],[-0.097,-0.179],[-1.154,-3.414],[-0.378,-0.098],[0,0],[-0.116,-2.435]],"o":[[0.079,2.468],[0,0],[-0.368,0.092],[-0.936,3.392],[-0.09,0.185],[0.009,0.195],[0,0],[0.501,0.813],[0.069,1.447],[0,0],[-1.653,1.735],[0,0],[-0.334,-0.204],[-3.046,1.633],[-0.392,0.124],[0,0],[-0.511,2.393],[0,0],[-2.419,-0.019],[0,0],[-0.107,-0.385],[-3.358,-1.086],[-0.379,-0.201],[0,0],[-2.034,1.256],[0,0],[-1.004,-1.002],[-0.048,-1],[0,0],[0.095,-0.171],[-0.009,-0.19],[-1.719,-3.166],[-0.137,-0.406],[0,0],[-2.327,-0.61],[0,0],[-0.079,-2.468],[0,0],[0.367,-0.092],[0.934,-3.384],[0.09,-0.186],[-0.009,-0.194],[0,0],[-0.501,-0.813],[-0.068,-1.446],[0,0],[1.652,-1.738],[0,0],[0.338,0.206],[3.041,-1.636],[0.397,-0.126],[0,0],[0.51,-2.393],[0,0],[2.417,0.018],[0,0],[0.106,0.379],[3.347,1.087],[0.374,0.199],[0,0],[2.03,-1.252],[0,0],[1.007,1.001],[0.047,1],[0,0],[-0.097,0.172],[0.009,0.193],[1.721,3.159],[0.138,0.408],[0,0],[2.331,0.605],[0,0.011]],"v":[[74.294,9.393],[70.483,14.576],[57.862,17.745],[57.099,18.535],[53.409,28.393],[53.286,28.984],[53.453,29.527],[60.565,41.072],[61.4,43.758],[59.984,47.633],[48.138,60.053],[41.738,60.816],[30.256,53.844],[29.145,53.829],[19.615,57.85],[18.859,58.65],[16.085,71.645],[11.111,75.676],[-6.199,75.546],[-11.437,71.436],[-15.044,58.413],[-15.862,57.589],[-25.66,53.424],[-26.784,53.424],[-37.805,60.23],[-44.261,59.374],[-56.918,46.766],[-58.579,43.033],[-57.925,40.195],[-51.558,28.747],[-51.427,28.172],[-51.588,27.611],[-55.924,17.699],[-56.74,16.896],[-69.576,13.54],[-73.725,8.376],[-74.295,-9.413],[-70.483,-14.596],[-57.863,-17.766],[-57.099,-18.555],[-53.416,-28.391],[-53.294,-28.984],[-53.46,-29.526],[-60.573,-41.072],[-61.408,-43.755],[-59.997,-47.626],[-48.171,-60.059],[-41.768,-60.827],[-30.276,-53.852],[-29.154,-53.837],[-19.623,-57.867],[-18.859,-58.678],[-16.093,-71.645],[-11.119,-75.676],[6.191,-75.546],[11.429,-71.439],[15.043,-58.424],[15.85,-57.611],[25.663,-53.434],[26.772,-53.432],[37.801,-60.233],[44.25,-59.379],[56.914,-46.792],[58.579,-43.055],[57.924,-40.216],[51.557,-28.762],[51.426,-28.182],[51.589,-27.615],[55.919,-17.71],[56.745,-16.899],[69.569,-13.561],[73.725,-8.397]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.187999994615,0.231000010173,0.438999998803,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[74.623,75.946],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120.0000048877,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.test.tsx b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.test.tsx new file mode 100644 index 000000000..d76e7916d --- /dev/null +++ b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.test.tsx @@ -0,0 +1,128 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FeedbackReporterModal } from "./FeedbackReporterModal"; + +vi.mock("motion/react", () => ({ + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, + motion: { + div: ({ children, ...props }: React.ComponentProps<"div">) =>
{children}
, + }, +})); + +vi.mock("../shared/ProviderModelSelector", () => ({ + ProviderModelSelector: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => ( + + ), +})); + +describe("FeedbackReporterModal", () => { + const originalAde = globalThis.window.ade; + const submissions = [ + { + id: "failed-1", + category: "bug", + userDescription: "The report failed and I need to see what I originally submitted.", + modelId: "anthropic/claude-opus-4-6", + status: "failed", + generatedTitle: null, + generatedBody: null, + issueUrl: null, + issueNumber: null, + issueState: null, + error: "Posting failed: GitHub API unavailable", + createdAt: "2026-04-08T05:19:57.903Z", + completedAt: "2026-04-08T05:21:34.368Z", + }, + { + id: "posted-1", + category: "enhancement", + userDescription: "Please add a way to expand the previous submissions tab.", + modelId: "anthropic/claude-opus-4-6", + status: "posted", + 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", + issueNumber: 144, + issueState: "open", + error: null, + createdAt: "2026-04-08T05:01:35.650Z", + completedAt: "2026-04-08T05:03:18.956Z", + }, + ]; + + beforeEach(() => { + globalThis.window.ade = { + github: { + getStatus: vi.fn(async () => ({ tokenStored: true })), + }, + feedback: { + list: vi.fn(async () => submissions), + onUpdate: vi.fn(() => () => {}), + submit: vi.fn(), + }, + app: { + openExternal: vi.fn(async () => undefined), + }, + } as any; + }); + + afterEach(() => { + cleanup(); + if (originalAde === undefined) { + delete (globalThis.window as any).ade; + } else { + globalThis.window.ade = originalAde; + } + }); + + it("shows failure details for failed submissions and lets users expand posted ones", async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /my submissions/i })); + + expect(await screen.findByText(/Posting failed: GitHub API unavailable/i)).toBeTruthy(); + expect( + screen.getByText(/The report failed and I need to see what I originally submitted\./i, { + selector: "div", + }), + ).toBeTruthy(); + + const postedToggle = await screen.findByRole("button", { + name: /Expandable submissions in feedback reporter/i, + }); + expect(postedToggle.getAttribute("aria-expanded")).toBe("false"); + + fireEvent.click(postedToggle); + + await waitFor(() => { + expect(postedToggle.getAttribute("aria-expanded")).toBe("true"); + }); + expect( + screen.getByText(/Please add a way to expand the previous submissions tab\./i), + ).toBeTruthy(); + expect( + screen.getByText(/Let users inspect the saved payload and error state\./i), + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx index bc678498e..559e82671 100644 --- a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx +++ b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx @@ -5,6 +5,8 @@ import { AnimatePresence, motion } from "motion/react"; import { ArrowSquareOut, Bug, + CaretDown, + CaretRight, ChatCircleDots, Lightbulb, Question, @@ -91,6 +93,13 @@ function statusLabel(status: FeedbackSubmission["status"]): { } } +function formatSubmissionTimestamp(value: string | null): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + function NewReportTab({ hasGithubToken, onSubmitted, @@ -259,85 +268,206 @@ function NewReportTab({ } function SubmissionRow({ submission }: { submission: FeedbackSubmission }) { + const [expanded, setExpanded] = useState(submission.status === "failed"); const sl = statusLabel(submission.status); const preview = submission.generatedTitle || submission.userDescription.slice(0, 80); + useEffect(() => { + if (submission.status === "failed") { + setExpanded(true); + } + }, [submission.status]); + return (
- setExpanded((value) => !value)} + aria-expanded={expanded} style={{ - ...categoryBadgeStyle(submission.category), - display: "inline-flex", + display: "flex", alignItems: "center", - gap: 4, - padding: "2px 6px", - fontSize: 10, - fontWeight: 700, - fontFamily: MONO_FONT, - textTransform: "uppercase", - flexShrink: 0, + gap: 8, + width: "100%", + padding: "8px 12px", + background: "transparent", + border: "none", + cursor: "pointer", + textAlign: "left", }} > - {categoryIcon(submission.category, 10)} - {submission.category} - + {expanded ? ( + + ) : ( + + )} - - {preview} - + + {categoryIcon(submission.category, 10)} + {submission.category} + - - {sl.text} - + + {preview} + - {submission.issueUrl ? ( - + {sl.text} + + + + {expanded ? ( +
+
+
+
Submitted
+
{formatSubmissionTimestamp(submission.createdAt)}
+
+
+
Completed
+
{formatSubmissionTimestamp(submission.completedAt)}
+
+
+
Model
+
{submission.modelId}
+
+
+
Issue
+ {submission.issueUrl ? ( + + ) : ( +
Not posted
+ )} +
+
+ +
+
Original submission
+
+ {submission.userDescription} +
+
+ + {submission.error ? ( +
+
Failure reason
+
+ {submission.error} +
+
+ ) : null} + + {submission.generatedTitle ? ( +
+
Generated title
+
{submission.generatedTitle}
+
+ ) : null} + + {submission.generatedBody ? ( +
+
Generated issue body
+
+ {submission.generatedBody} +
+
+ ) : null} +
) : null}
); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 99dfdd6c5..c13055ea2 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -853,6 +853,17 @@ export function AgentChatComposer({ sessionProvider, opencodePermissionMode, ]); + + const composerGlowColor = useMemo(() => { + const provider = sessionProvider ?? (modelId ? "anthropic" : null); + if (!provider) return null; + if (provider === "anthropic") return "rgba(249, 115, 22, 0.25)"; + if (provider === "openai") return "rgba(255, 255, 255, 0.15)"; + if (provider === "cursor") return "rgba(59, 130, 246, 0.25)"; + if (provider === "opencode") return "rgba(255, 255, 255, 0.12)"; + return null; + }, [sessionProvider, modelId]); + /* ── Keyboard handler for textarea ── */ const handleKeyDown = (event: React.KeyboardEvent) => { const commandModified = event.metaKey || event.ctrlKey; @@ -977,6 +988,7 @@ export function AgentChatComposer({ <> {slashPickerOpen && filteredSlashCommands.length > 0 ? ( -
+
Commands
@@ -1098,7 +1110,7 @@ export function AgentChatComposer({ ) : null} {attachmentPickerOpen ? ( -
+
setAttachmentQuery(e.target.value)} placeholder="Search files..." - className="h-5 flex-1 bg-transparent font-mono text-[11px] text-fg/80 outline-none placeholder:text-muted-fg/25" + className="h-5 flex-1 bg-transparent font-sans text-[11px] text-fg/80 outline-none placeholder:text-muted-fg/25" onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setAttachmentPickerOpen(false); return; } if (event.key === "ArrowDown") { event.preventDefault(); setAttachmentCursor((v) => Math.min(v + 1, Math.max(attachmentResults.length - 1, 0))); return; } @@ -1130,7 +1142,7 @@ export function AgentChatComposer({ type="button" className={cn( "flex w-full items-center gap-2 px-3 py-2 text-left font-mono text-[10px] text-fg/60", - index === attachmentCursor ? "bg-accent/10 text-fg/85" : "hover:bg-border/6", + index === attachmentCursor ? "bg-violet-500/[0.08] text-fg/85" : "hover:bg-white/[0.03]", )} onMouseEnter={() => setAttachmentCursor(index)} onClick={() => selectAttachment(result)} @@ -1148,7 +1160,7 @@ export function AgentChatComposer({ } footer={ -
+
{/* Left: permission + model controls */}
{nativeControlPanel} @@ -1165,10 +1177,10 @@ export function AgentChatComposer({
{/* Right: attachment, commands, proof, context, send */} -
+
) : null} @@ -1264,7 +1275,7 @@ export function AgentChatComposer({ ) : null} )}
@@ -1371,7 +1382,7 @@ export function AgentChatComposer({ } }} className={cn( - "min-h-[44px] w-full bg-transparent px-4 py-2.5 text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/25", + "min-h-[44px] w-full bg-transparent px-4 py-2.5 text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/30", layoutVariant === "grid-tile" ? "resize-y" : "max-h-[200px] resize-none", dragActive ? "opacity-30" : "", )} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 815b80880..d755e6d55 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -26,6 +26,8 @@ import { CopySimple, Brain, } from "@phosphor-icons/react"; +import { useLottie } from "lottie-react"; +import brainThinkingAnimation from "../../assets/brain-thinking.json"; import type { AgentChatApprovalDecision, AgentChatEvent, @@ -36,7 +38,7 @@ import type { ChatSurfaceProfile, ChatSurfaceMode, OperatorNavigationSuggestion, - TurnDiffSummary, + TurnDiffFile, } from "../../../shared/types"; import { getModelById, resolveModelDescriptor } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; @@ -49,7 +51,6 @@ import { ClaudeLogo, CodexLogo, CursorAgentLogo } from "../terminals/ToolLogos"; import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; import { ChatWorkLogBlock } from "./ChatWorkLogBlock"; import { ChatStatusGlyph } from "./chatStatusVisuals"; -import { ChatTurnDiffPanel } from "./ChatTurnDiffPanel"; import { collapseChatTranscriptEventsIncremental, formatStructuredValue, @@ -130,6 +131,21 @@ function formatFileAction(kind: Extract } } +function formatTurnDiffAction(status: TurnDiffFile["status"]): string { + switch (status) { + case "A": + return "Created"; + case "D": + return "Deleted"; + case "R": + return "Renamed"; + case "C": + return "Copied"; + default: + return "Edited"; + } +} + function hasNoticeDetail(detail: string | AgentChatNoticeDetail | undefined): boolean { if (detail == null) return false; if (typeof detail === "string") return detail.trim().length > 0; @@ -225,13 +241,13 @@ function renderSubagentUsage(usage: { } const GLASS_CARD_CLASS = - "overflow-hidden rounded-[14px] border border-white/[0.08] bg-[#141220]"; + "overflow-hidden rounded-[14px] border border-white/[0.06] bg-[#141220] shadow-[0_2px_12px_-4px_rgba(0,0,0,0.4)]"; const WORK_LOG_CARD_CLASS = - "border border-white/[0.06] bg-[#13111B]/70"; + "border border-white/[0.05] bg-[#13111B]/80 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.3)]"; const RECESSED_BLOCK_CLASS = - "overflow-auto whitespace-pre-wrap break-words rounded-[10px] border border-white/[0.05] bg-[#0A090E] px-4 py-3 font-mono text-[11px] leading-[1.6] text-fg/76"; + "overflow-auto whitespace-pre-wrap break-words rounded-[10px] border border-white/[0.05] bg-[#09080D] px-4 py-3 font-mono text-[11px] leading-[1.6] text-fg/78"; function toolSourceChip(toolName: string): { label: string; tone: ChatSurfaceChipTone } | null { if (toolName.startsWith("mcp__")) { @@ -253,18 +269,20 @@ function toolSourceChip(toolName: string): { label: string; tone: ChatSurfaceChi } const MESSAGE_CARD_STYLE: React.CSSProperties = { - borderColor: "rgba(167, 139, 250, 0.14)", - background: "#191624", + background: "linear-gradient(135deg, #7C3AED 0%, #6D28D9 50%, #5B21B6 100%)", + borderColor: "rgba(167, 139, 250, 0.30)", + boxShadow: "0 4px 16px -4px rgba(124, 58, 237, 0.35)", }; const SURFACE_INLINE_CARD_STYLE: React.CSSProperties = { - borderColor: "rgba(255, 255, 255, 0.08)", - background: "#16141E", + borderColor: "rgba(255, 255, 255, 0.06)", + background: "#15131E", }; const ASSISTANT_MESSAGE_CARD_STYLE: React.CSSProperties = { - borderColor: "rgba(148, 163, 184, 0.10)", + borderColor: "rgba(255, 255, 255, 0.06)", background: "#12101A", + boxShadow: "0 2px 12px -4px rgba(0, 0, 0, 0.4)", }; function describeUserDeliveryState(event: Extract): { label: string; className: string } | null { @@ -335,7 +353,7 @@ function MessageCopyButton({ {expandable && open ? ( -
+
{children}
) : null} @@ -562,7 +580,7 @@ const MarkdownBlock = React.memo(function MarkdownBlock({ ), table: ({ children }) => ( -
+
{children}
), @@ -570,7 +588,7 @@ const MarkdownBlock = React.memo(function MarkdownBlock({ tbody: ({ children, node: _, ...props }) => {children}, tr: ({ children, node: _, ...props }) => {children}, th: ({ children, node: _, ...props }) => ( - + {children} ), @@ -679,12 +697,12 @@ function CollapsibleCard({ const isOpen = forceOpen === true ? !userCollapsed : open; return ( -
+
- {isOpen ?
{children}
: null} + {isOpen ?
{children}
: null}
); } @@ -741,14 +759,25 @@ const ACTIVITY_LABELS: Record = { tool_calling: "Calling tool" }; +function BrainLottie({ loop, size = 24 }: { loop: boolean; size?: number }) { + const { View } = useLottie({ + animationData: brainThinkingAnimation, + loop, + autoplay: true, + style: { width: size, height: size, display: "inline-block" }, + rendererSettings: { preserveAspectRatio: "xMidYMid slice" }, + }); + return <>{View}; +} + function ThinkingDots({ toneClass = "bg-emerald-300/70" }: { toneClass?: string }) { return ( -