From eb2ef1da03ada073b258b820aa35e7128af07965 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:05:42 -0400 Subject: [PATCH] feat(chat): add ACP providers to model picker --- apps/ade-cli/src/adeRpcServer.ts | 4 +- apps/ade-cli/src/cli.test.ts | 24 +- apps/ade-cli/src/cli.ts | 12 +- apps/ade-cli/src/services/agentRegistry.ts | 67 + .../src/services/push/attentionItemBuilder.ts | 10 + .../services/sync/syncRemoteCommandService.ts | 43 +- apps/ade-cli/src/tuiClient/adeApi.ts | 29 +- apps/ade-cli/src/tuiClient/app.tsx | 5 + .../src/tuiClient/closedCliSessions.ts | 14 +- .../tuiClient/components/ApprovalPrompt.tsx | 15 +- .../tuiClient/components/CommandPalette.tsx | 5 + .../ModelPicker/ModelPickerPane.tsx | 10 + .../ModelPicker/modelPickerLayout.test.ts | 104 + .../ModelPicker/modelPickerLayout.ts | 83 +- .../src/tuiClient/components/SlashPalette.tsx | 4 + apps/ade-cli/src/tuiClient/modelState.ts | 4 +- .../ade-cli/src/tuiClient/providerMetadata.ts | 82 +- apps/ade-cli/src/tuiClient/remoteLauncher.ts | 54 +- apps/ade-cli/src/tuiClient/theme.ts | 7 + apps/ade-cli/src/tuiClient/types.ts | 8 +- .../src/main/services/ai/acpAuthProbe.test.ts | 82 + .../src/main/services/ai/acpAuthProbe.ts | 359 ++++ .../main/services/ai/acpExecutables.test.ts | 54 + .../src/main/services/ai/acpExecutables.ts | 162 ++ .../ai/acpProviderDiagnostics.test.ts | 101 + .../services/ai/acpProviderDiagnostics.ts | 168 ++ .../services/ai/aiIntegrationService.test.ts | 67 + .../main/services/ai/aiIntegrationService.ts | 170 +- .../src/main/services/ai/authDetector.test.ts | 15 + .../src/main/services/ai/authDetector.ts | 113 +- .../services/ai/claudeCodeExecutable.test.ts | 17 +- .../services/ai/cliExecutableResolver.test.ts | 27 + .../main/services/ai/cliExecutableResolver.ts | 8 + .../main/services/ai/codexExecutable.test.ts | 21 +- .../ai/grokPermissionPreflight.test.ts | 271 +++ .../services/ai/grokPermissionPreflight.ts | 444 ++++ .../services/ai/providerConnectionStatus.ts | 55 +- .../main/services/ai/providerRuntimeHealth.ts | 10 +- .../main/services/ai/qwenUserSettings.test.ts | 75 + .../src/main/services/ai/qwenUserSettings.ts | 107 + .../services/chat/acpHost/acpConnection.ts | 553 +++++ .../chat/acpHost/acpDialects/copilot.ts | 192 ++ .../services/chat/acpHost/acpDialects/grok.ts | 302 +++ .../chat/acpHost/acpDialects/index.ts | 38 + .../services/chat/acpHost/acpDialects/kimi.ts | 115 ++ .../services/chat/acpHost/acpDialects/qwen.ts | 109 + .../chat/acpHost/acpDialects/shared.ts | 94 + .../chat/acpHost/acpEventTranslator.ts | 582 ++++++ .../chat/acpHost/acpHost.fixtures.test.ts | 168 ++ .../chat/acpHost/acpHost.live.test.ts | 301 +++ .../services/chat/acpHost/acpHost.test.ts | 1836 +++++++++++++++++ .../services/chat/acpHost/acpHostTypes.ts | 382 ++++ .../chat/acpHost/acpPermissionBridge.ts | 245 +++ .../chat/acpHost/acpPromptBlocks.test.ts | 105 + .../services/chat/acpHost/acpPromptBlocks.ts | 117 ++ .../services/chat/acpHost/acpProtocolTypes.ts | 684 ++++++ .../chat/acpHost/acpRuntimeCoordinator.ts | 232 +++ .../main/services/chat/acpHost/acpSession.ts | 506 +++++ .../services/chat/acpHost/acpSessionPool.ts | 267 +++ .../chat/acpHost/acpSupervisionGuard.ts | 218 ++ .../fixtures/copilot.config-options.json | 38 + .../acpHost/fixtures/copilot.initialize.json | 35 + .../acpHost/fixtures/copilot.live-turn.json | 176 ++ .../acpHost/fixtures/copilot.model-probe.json | 149 ++ .../acpHost/fixtures/copilot.trust-gate.json | 501 +++++ .../chat/acpHost/fixtures/copilotLiveTurn.mjs | 322 +++ .../acpHost/fixtures/copilotModelProbe.mjs | 257 +++ .../fixtures/copilotTrustGateProbe.mjs | 500 +++++ .../acpHost/fixtures/grok.followup-probe.json | 189 ++ .../acpHost/fixtures/grok.initialize.json | 204 ++ .../fixtures/grok.permission-probe.json | 7 + .../fixtures/grok.promptResult.meta.json | 36 + .../fixtures/grok.shell-permission-probe.json | 5 + .../acpHost/fixtures/grokFollowupProbe.mjs | 491 +++++ .../acpHost/fixtures/grokPermissionProbe.mjs | 120 ++ .../acpHost/fixtures/kimi.initialize.json | 57 + .../chat/acpHost/fixtures/liveBinaryProbe.mjs | 528 +++++ .../fixtures/qwen-kimi-close-probe.json | 63 + .../acpHost/fixtures/qwen.initialize.json | 44 + .../acpHost/fixtures/qwenKimiCloseProbe.mjs | 78 + .../acpHost/fixtures/qwenKimiUnauthProbe.mjs | 342 +++ .../src/main/services/chat/acpHost/index.ts | 107 + .../services/chat/acpHost/mockAcpAgent.ts | 250 +++ .../services/chat/agentChatService.test.ts | 644 +++++- .../main/services/chat/agentChatService.ts | 1120 +++++++++- .../config/projectConfigService.test.ts | 16 + .../services/config/projectConfigService.ts | 29 +- .../src/main/services/ipc/registerIpc.ts | 22 +- .../src/main/services/lanes/laneService.ts | 10 +- .../lanes/laneStorageLifecycle.test.ts | 18 +- .../src/main/services/pty/ptyService.test.ts | 54 + .../src/main/services/pty/ptyService.ts | 209 +- .../services/shared/providerConfigHomes.ts | 27 + .../src/main/utils/terminalTuiMarkers.ts | 18 + apps/desktop/src/preload/global.d.ts | 9 + apps/desktop/src/preload/preload.ts | 9 + .../renderer/components/app/SettingsPage.tsx | 80 +- .../chat/AgentChatComposer.test.tsx | 1 + .../chat/AgentChatMessageList.test.tsx | 1 + .../components/chat/AgentChatPane.test.tsx | 1 + .../components/chat/AgentChatPane.tsx | 46 +- .../components/chat/AgentCliAuthCard.tsx | 60 +- .../components/prs/state/PrsContext.tsx | 13 +- .../settings/ChatAppearancePreview.test.tsx | 1 + .../settings/ProvidersSection.test.tsx | 346 +++- .../components/settings/ProvidersSection.tsx | 1467 ++++--------- .../settings/providerSectionPrimitives.tsx | 50 +- .../settings/providers/ProviderDetailPage.tsx | 362 ++++ .../providers/ProviderSignInModal.tsx | 288 +++ .../settings/providers/ProviderTileCard.tsx | 109 + .../settings/providers/acpProviders.tsx | 356 ++++ .../providers/bodies/CliAuthActions.tsx | 55 + .../settings/providers/bodies/CursorBody.tsx | 198 ++ .../providers/bodies/OpenCodeBody.tsx | 382 ++++ .../settings/providers/bodies/PiBody.tsx | 78 + .../components/settings/providers/cliTools.ts | 189 ++ .../settings/providers/descriptors.tsx | 343 +++ .../providers/providerDiagnosticsReport.ts | 47 + .../settings/providers/providerUi.test.ts | 43 + .../settings/providers/providerUi.tsx | 321 +++ .../components/settings/providers/types.ts | 286 +++ .../settings/settingsManifest.test.ts | 52 + .../components/settings/settingsManifest.ts | 111 + .../shared/ModelPicker/ModelPicker.test.tsx | 146 ++ .../shared/ModelPicker/ModelPicker.tsx | 16 +- .../shared/ModelPicker/ModelPickerContent.tsx | 75 +- .../shared/ModelPicker/ModelPickerRail.tsx | 2 +- .../ReasoningEffortPicker.test.tsx | 1 + .../shared/ModelPicker/modelCatalog.test.ts | 22 +- .../shared/ModelPicker/modelCatalog.ts | 96 +- .../shared/ModelPicker/providerEmptyState.tsx | 40 + .../shared/ModelPicker/runtimeCatalogCache.ts | 25 +- .../ModelPicker/useProviderAuthStatus.test.ts | 24 + .../ModelPicker/useProviderAuthStatus.ts | 46 +- .../components/shared/ProviderLogos.tsx | 4 + .../components/shared/permissionOptions.ts | 85 +- .../terminals/WorkViewArea.test.tsx | 1 + .../components/terminals/cliLaunch.test.ts | 165 ++ apps/desktop/src/renderer/lib/modelOptions.ts | 9 + .../src/renderer/lib/nativeLaunchControls.ts | 12 +- .../desktop/src/renderer/lib/sessions.test.ts | 6 +- apps/desktop/src/renderer/lib/sessions.ts | 50 +- .../desktop/src/shared/acpProviderMetadata.ts | 47 + apps/desktop/src/shared/cliLaunch.ts | 406 +++- apps/desktop/src/shared/grokSupervision.ts | 64 + apps/desktop/src/shared/ipc.ts | 6 + apps/desktop/src/shared/modelCatalog.test.ts | 40 +- apps/desktop/src/shared/modelCatalog.ts | 85 +- apps/desktop/src/shared/modelRegistry.test.ts | 75 + apps/desktop/src/shared/modelRegistry.ts | 523 ++++- .../shared/orchestrationRuntimePolicy.test.ts | 4 + .../src/shared/orchestrationRuntimePolicy.ts | 13 + apps/desktop/src/shared/pendingInputLabels.ts | 14 + .../src/shared/providerEnablement.test.ts | 48 + apps/desktop/src/shared/providerEnablement.ts | 71 + apps/desktop/src/shared/types/chat.ts | 117 +- apps/desktop/src/shared/types/config.ts | 69 +- apps/desktop/src/shared/types/sessions.ts | 42 +- apps/desktop/src/shared/types/sync.ts | 13 +- .../ProviderKimi.imageset/Contents.json | 15 + .../ProviderKimi.imageset/kimi.svg | 1 + .../ProviderQwen.imageset/Contents.json | 15 + .../ProviderQwen.imageset/qwen.svg | 1 + .../ProviderXAI.imageset/Contents.json | 15 + .../ProviderXAI.imageset/xai.svg | 1 + apps/ios/ADE/Shared/ADESharedTheme.swift | 30 + .../Views/Components/ADEDesignSystem.swift | 8 + .../Work/WorkContextCompactDivider.swift | 4 + .../ios/ADE/Views/Work/WorkModelCatalog.swift | 89 +- .../ADE/Views/Work/WorkModelPickerSheet.swift | 3 +- apps/ios/ADE/Views/Work/WorkModels.swift | 16 + .../ADE/Views/Work/WorkNewChatScreen.swift | 4 + .../ios/ADE/Views/Work/WorkNewChatSheet.swift | 48 +- .../Views/Work/WorkRootScreen+Actions.swift | 4 + .../Work/WorkSessionDestinationView.swift | 4 + .../Work/WorkStatusAndFormattingHelpers.swift | 55 +- apps/ios/ADETests/ADETests.swift | 6 + docs/features/chat/acp-providers-spec.md | 436 ++++ docs/features/chat/acp-verification-brief.md | 139 ++ docs/features/chat/composer-and-ui.md | 4 +- .../configuration-schema.md | 17 + 181 files changed, 23867 insertions(+), 1436 deletions(-) create mode 100644 apps/desktop/src/main/services/ai/acpAuthProbe.test.ts create mode 100644 apps/desktop/src/main/services/ai/acpAuthProbe.ts create mode 100644 apps/desktop/src/main/services/ai/acpExecutables.test.ts create mode 100644 apps/desktop/src/main/services/ai/acpExecutables.ts create mode 100644 apps/desktop/src/main/services/ai/acpProviderDiagnostics.test.ts create mode 100644 apps/desktop/src/main/services/ai/acpProviderDiagnostics.ts create mode 100644 apps/desktop/src/main/services/ai/grokPermissionPreflight.test.ts create mode 100644 apps/desktop/src/main/services/ai/grokPermissionPreflight.ts create mode 100644 apps/desktop/src/main/services/ai/qwenUserSettings.test.ts create mode 100644 apps/desktop/src/main/services/ai/qwenUserSettings.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpConnection.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpDialects/copilot.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpDialects/grok.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpDialects/index.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpDialects/kimi.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpDialects/qwen.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpDialects/shared.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpEventTranslator.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpHost.fixtures.test.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpHost.live.test.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpHostTypes.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpPermissionBridge.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpPromptBlocks.test.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpPromptBlocks.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpProtocolTypes.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpRuntimeCoordinator.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpSession.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpSessionPool.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/acpSupervisionGuard.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.config-options.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.initialize.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.live-turn.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.model-probe.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.trust-gate.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/copilotLiveTurn.mjs create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/copilotModelProbe.mjs create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/copilotTrustGateProbe.mjs create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/grok.followup-probe.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/grok.initialize.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/grok.permission-probe.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/grok.promptResult.meta.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/grok.shell-permission-probe.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/grokFollowupProbe.mjs create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/grokPermissionProbe.mjs create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/kimi.initialize.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/liveBinaryProbe.mjs create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/qwen-kimi-close-probe.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/qwen.initialize.json create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/qwenKimiCloseProbe.mjs create mode 100644 apps/desktop/src/main/services/chat/acpHost/fixtures/qwenKimiUnauthProbe.mjs create mode 100644 apps/desktop/src/main/services/chat/acpHost/index.ts create mode 100644 apps/desktop/src/main/services/chat/acpHost/mockAcpAgent.ts create mode 100644 apps/desktop/src/renderer/components/settings/providers/ProviderDetailPage.tsx create mode 100644 apps/desktop/src/renderer/components/settings/providers/ProviderSignInModal.tsx create mode 100644 apps/desktop/src/renderer/components/settings/providers/ProviderTileCard.tsx create mode 100644 apps/desktop/src/renderer/components/settings/providers/acpProviders.tsx create mode 100644 apps/desktop/src/renderer/components/settings/providers/bodies/CliAuthActions.tsx create mode 100644 apps/desktop/src/renderer/components/settings/providers/bodies/CursorBody.tsx create mode 100644 apps/desktop/src/renderer/components/settings/providers/bodies/OpenCodeBody.tsx create mode 100644 apps/desktop/src/renderer/components/settings/providers/bodies/PiBody.tsx create mode 100644 apps/desktop/src/renderer/components/settings/providers/cliTools.ts create mode 100644 apps/desktop/src/renderer/components/settings/providers/descriptors.tsx create mode 100644 apps/desktop/src/renderer/components/settings/providers/providerDiagnosticsReport.ts create mode 100644 apps/desktop/src/renderer/components/settings/providers/providerUi.test.ts create mode 100644 apps/desktop/src/renderer/components/settings/providers/providerUi.tsx create mode 100644 apps/desktop/src/renderer/components/settings/providers/types.ts create mode 100644 apps/desktop/src/shared/acpProviderMetadata.ts create mode 100644 apps/desktop/src/shared/grokSupervision.ts create mode 100644 apps/desktop/src/shared/providerEnablement.test.ts create mode 100644 apps/desktop/src/shared/providerEnablement.ts create mode 100644 apps/ios/ADE/Assets.xcassets/ProviderKimi.imageset/Contents.json create mode 100644 apps/ios/ADE/Assets.xcassets/ProviderKimi.imageset/kimi.svg create mode 100644 apps/ios/ADE/Assets.xcassets/ProviderQwen.imageset/Contents.json create mode 100644 apps/ios/ADE/Assets.xcassets/ProviderQwen.imageset/qwen.svg create mode 100644 apps/ios/ADE/Assets.xcassets/ProviderXAI.imageset/Contents.json create mode 100644 apps/ios/ADE/Assets.xcassets/ProviderXAI.imageset/xai.svg create mode 100644 docs/features/chat/acp-providers-spec.md create mode 100644 docs/features/chat/acp-verification-brief.md diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 90d171ff2b..06259937ec 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -332,7 +332,7 @@ const TOOL_SPECS: ToolSpec[] = [ additionalProperties: false, properties: { laneId: { type: "string", minLength: 1 }, - provider: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "pi", "shell"] }, + provider: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "pi", "qwen", "kimi", "grok", "copilot", "shell"] }, permissionMode: { type: "string", enum: ["default", "auto", "plan", "edit", "full-auto", "config-toml"], default: "default" }, title: { type: "string" }, initialInput: { type: "string" }, @@ -1698,7 +1698,7 @@ function parseCliSessionProvider(value: unknown): LaunchProfile { if (!isLaunchProfile(provider)) { throw new JsonRpcError( JsonRpcErrorCode.invalidParams, - "provider must be one of claude, codex, cursor, droid, opencode, pi, or shell", + "provider must be one of claude, codex, cursor, droid, opencode, pi, qwen, kimi, grok, copilot, or shell", ); } return provider; diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 66b217fc91..c84d29c814 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -992,6 +992,8 @@ describe("ADE CLI", () => { ADE_PACKAGE_CHANNEL: process.env.ADE_PACKAGE_CHANNEL, ADE_SYNC_HOST_LOCK_PATH: process.env.ADE_SYNC_HOST_LOCK_PATH, ADE_SYNC_HOST_SINGLETON_TEST_MODE: process.env.ADE_SYNC_HOST_SINGLETON_TEST_MODE, + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL, + ADE_DISABLE_TOOLS_FETCH: process.env.ADE_DISABLE_TOOLS_FETCH, }; const ownerProcess = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000);"], { stdio: "ignore", @@ -1008,6 +1010,8 @@ describe("ADE CLI", () => { delete process.env.ADE_PACKAGE_CHANNEL; process.env.ADE_SYNC_HOST_LOCK_PATH = lockPath; process.env.ADE_SYNC_HOST_SINGLETON_TEST_MODE = "1"; + process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL = "1"; + process.env.ADE_DISABLE_TOOLS_FETCH = "1"; writeSyncHostSingletonLock({ lockPath, pid: ownerProcess.pid, @@ -1031,6 +1035,10 @@ describe("ADE CLI", () => { else process.env.ADE_SYNC_HOST_LOCK_PATH = originalEnv.ADE_SYNC_HOST_LOCK_PATH; if (originalEnv.ADE_SYNC_HOST_SINGLETON_TEST_MODE === undefined) delete process.env.ADE_SYNC_HOST_SINGLETON_TEST_MODE; else process.env.ADE_SYNC_HOST_SINGLETON_TEST_MODE = originalEnv.ADE_SYNC_HOST_SINGLETON_TEST_MODE; + if (originalEnv.ADE_DISABLE_RUNTIME_SERVICE_INSTALL === undefined) delete process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL; + else process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL = originalEnv.ADE_DISABLE_RUNTIME_SERVICE_INSTALL; + if (originalEnv.ADE_DISABLE_TOOLS_FETCH === undefined) delete process.env.ADE_DISABLE_TOOLS_FETCH; + else process.env.ADE_DISABLE_TOOLS_FETCH = originalEnv.ADE_DISABLE_TOOLS_FETCH; ownerProcess.kill("SIGKILL"); fs.rmSync(adeHome, { recursive: true, force: true }); } @@ -3411,7 +3419,21 @@ describe("ADE CLI", () => { "--provider", "mystery", ]), - ).toThrow(/Provider must be claude, codex, cursor, droid, opencode, pi, or shell/); + ).toThrow(/Provider must be claude, codex, cursor, droid, opencode, pi, qwen, kimi, grok, copilot, or shell/); + }); + + it("accepts ACP providers for new chat", () => { + for (const provider of ["qwen", "kimi", "grok", "copilot"] as const) { + const plan = buildCliPlan([ + "new", + "chat", + "--lane", + "lane-1", + "--provider", + provider, + ]); + expect(plan.kind).toBe("execute"); + } }); it("does not treat new --mode values as subcommands", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 885d120384..3a7c7bdd0a 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1763,7 +1763,7 @@ const HELP_BY_COMMAND: Record = { --type Required for a parented agent spawn. Use subagent whenever you will need, join, or review the result; use peer only for fire-and-forget work. - --provider claude | codex | cursor | droid | opencode | pi. CLI mode also accepts shell. + --provider claude | codex | cursor | droid | opencode | pi | qwen | kimi | grok | copilot. CLI mode also accepts shell. --model Runtime model id. --reasoning-effort Reasoning tier. Alias: --effort. --permissions default | auto | plan | edit | full-auto | config-toml. @@ -2166,7 +2166,7 @@ const HELP_BY_COMMAND: Record = { Start a tracked provider CLI session Create flags: - --provider claude | codex | cursor | droid | opencode | pi. + --provider claude | codex | cursor | droid | opencode | pi | qwen | kimi | grok | copilot. --model Model id, also sent as modelId for runtime parity. --reasoning-effort Reasoning tier when the selected model supports it. Common tiers: minimal, low, medium, high, xhigh, ultra, ultracode. @@ -2226,7 +2226,7 @@ const HELP_BY_COMMAND: Record = { Flags: --personal Use machine-owned chats instead of a project/lane chat. --lane Lane/worktree for the chat. - --provider claude | codex | cursor | droid | opencode | pi. + --provider claude | codex | cursor | droid | opencode | pi | qwen | kimi | grok | copilot. --model Model id, also sent as modelId for runtime parity. --reasoning-effort Reasoning tier when supported by the model. --effort Alias for --reasoning-effort. @@ -5006,10 +5006,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan const printConfig = readFlag(args, ["--print-config", "--dry-run"]); if (!isLaunchProfile(provider)) { - throw new CliUsageError("Provider must be claude, codex, cursor, droid, opencode, pi, or shell."); + throw new CliUsageError("Provider must be claude, codex, cursor, droid, opencode, pi, qwen, kimi, grok, copilot, or shell."); } if (mode === "chat" && provider === "shell") { - throw new CliUsageError("Chat mode provider must be claude, codex, cursor, droid, opencode, or pi."); + throw new CliUsageError("Chat mode provider must be claude, codex, cursor, droid, opencode, pi, qwen, kimi, grok, or copilot."); } if (mode === "cli") { const effectivePermissionMode = permissionMode ?? "default"; @@ -6973,7 +6973,7 @@ function buildCliSessionStartPlan( ); if (!isLaunchProfile(rawProvider)) { throw new CliUsageError( - "provider must be one of claude, codex, cursor, droid, opencode, pi, or shell.", + "provider must be one of claude, codex, cursor, droid, opencode, pi, qwen, kimi, grok, copilot, or shell.", ); } const provider: LaunchProfile = rawProvider; diff --git a/apps/ade-cli/src/services/agentRegistry.ts b/apps/ade-cli/src/services/agentRegistry.ts index 147f7551db..d9a3cd32e0 100644 --- a/apps/ade-cli/src/services/agentRegistry.ts +++ b/apps/ade-cli/src/services/agentRegistry.ts @@ -137,6 +137,73 @@ export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ /\bfactory(?:_api_key| api key)\b.*\b(invalid|missing|not found|not set|required|unauthorized|must be set)\b/i, ], }, + { + agent: "qwen", + displayName: "Qwen Code", + binaryNames: ["qwen"], + installCommand: npmGlobalInstallCommand("@qwen-code/qwen-code"), + // 0.22.3 removed `qwen auth`. Sign-in is OPENAI_API_KEY / `--auth-type=openai`. + authCommand: "qwen --auth-type=openai", + missingErrorPatterns: [ + /\bqwen\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+qwen\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bqwen\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required|no api key|api key required|no credentials)\b/i, + /\b(?:dashscope|openai)[_ ]api[_ ]key\b.*\b(invalid|missing|not found|not set|required|unauthorized|must be set)\b/i, + ], + }, + { + agent: "kimi", + displayName: "Kimi Code", + binaryNames: ["kimi"], + // Kimi ships a native binary rather than an npm package, so there is no + // portable one-liner to print here. Point at the vendor's own installer + // instead of guessing a package name that would fail on paste. + installCommand: "curl -LsSf https://code.kimi.com/kimi-code/install.sh | bash", + authCommand: "kimi login", + missingErrorPatterns: [ + /\bkimi\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+kimi\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bkimi\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required|no api key|api key required|no credentials)\b/i, + /\brun\s+[`'"]?kimi\s+login[`'"]?/i, + /\bmoonshot[_ ]api[_ ]key\b.*\b(invalid|missing|not found|not set|required|unauthorized|must be set)\b/i, + ], + }, + { + agent: "grok", + displayName: "Grok CLI", + binaryNames: ["grok"], + installCommand: npmGlobalInstallCommand("@xai-official/grok"), + authCommand: "grok login", + missingErrorPatterns: [ + /\bgrok\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+grok\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bgrok\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required|no api key|api key required|no credentials)\b/i, + /\brun\s+[`'"]?grok\s+login[`'"]?/i, + /\bxai[_ ]api[_ ]key\b.*\b(invalid|missing|not found|not set|required|unauthorized|must be set)\b/i, + ], + }, + { + agent: "copilot", + displayName: "GitHub Copilot CLI", + binaryNames: ["copilot"], + installCommand: npmGlobalInstallCommand("@github/copilot"), + authCommand: "copilot login", + missingErrorPatterns: [ + /\bcopilot\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+copilot\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bcopilot\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required|no credentials)\b/i, + /\brun\s+[`'"]?copilot\s+login[`'"]?/i, + /\bgh[_ ]token\b.*\b(invalid|missing|not found|not set|required|unauthorized|must be set)\b/i, + ], + }, ]; function descriptorMatchesPreferred(descriptor: AgentCliDescriptor, preferredAgent: string | null | undefined): boolean { diff --git a/apps/ade-cli/src/services/push/attentionItemBuilder.ts b/apps/ade-cli/src/services/push/attentionItemBuilder.ts index 45bb100ffe..4e83b0c54a 100644 --- a/apps/ade-cli/src/services/push/attentionItemBuilder.ts +++ b/apps/ade-cli/src/services/push/attentionItemBuilder.ts @@ -151,6 +151,16 @@ export function providerDisplayName(provider: string | null | undefined): string return "OpenCode"; case "gemini": return "Gemini"; + case "qwen": + return "Qwen"; + case "kimi": + return "Kimi"; + case "grok": + return "Grok"; + case "copilot": + // The default arm would title-case this to "Copilot"; the product name + // carries the vendor. + return "GitHub Copilot"; default: return provider.charAt(0).toUpperCase() + provider.slice(1); } diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 931f817803..40ad961dd5 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3141,22 +3141,31 @@ function parseChatModelsArgs(value: Record): { }; } +/** + * Providers a remote caller may ask the host to re-enumerate. Typed as the + * shared union so a provider added there is accepted here too, instead of being + * silently dropped from the request and leaving the phone's refresh a no-op. + */ +const MODEL_CATALOG_REFRESH_PROVIDERS = new Set([ + "opencode", + "pi", + "cursor", + "droid", + "lmstudio", + "ollama", + "qwen", + "kimi", + "grok", + "copilot", +]); + function parseChatModelCatalogArgs(value: Record): AgentChatModelCatalogArgs { const mode = asTrimmedString(value.mode) as AgentChatModelCatalogMode | null; const refreshProvider = asTrimmedString(value.refreshProvider) as AgentChatModelCatalogRefreshProvider | null; const cursorSource = parseCursorModelSource(value.cursorSource); return { ...(mode === "cached" || mode === "refresh-stale" || mode === "force" ? { mode } : {}), - ...( - refreshProvider === "opencode" - || refreshProvider === "pi" - || refreshProvider === "cursor" - || refreshProvider === "droid" - || refreshProvider === "lmstudio" - || refreshProvider === "ollama" - ? { refreshProvider } - : {} - ), + ...(refreshProvider && MODEL_CATALOG_REFRESH_PROVIDERS.has(refreshProvider) ? { refreshProvider } : {}), ...(cursorSource ? { cursorSource } : {}), }; } @@ -3661,7 +3670,19 @@ async function resolveChatCreateArgs( if (payload.model.trim().length > 0) return payload; const available = await service.getAvailableModels({ provider: payload.provider, - ...(payload.provider === "opencode" || payload.provider === "pi" ? { activateRuntime: true } : {}), + // ACP providers are here for the same reason as OpenCode/Pi: their model + // rows are gated on a CLI auth pass, and `activateRuntime` refreshes that + // pass. It does not spawn an agent for them. + ...( + payload.provider === "opencode" + || payload.provider === "pi" + || payload.provider === "qwen" + || payload.provider === "kimi" + || payload.provider === "grok" + || payload.provider === "copilot" + ? { activateRuntime: true } + : {} + ), }); const chosen = available[0]; if (!chosen) { diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index fc2d09f804..a6c293598e 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -423,6 +423,10 @@ const CHAT_BACKED_TERMINAL_TOOL_TYPES = new Set([ "cursor", "droid-chat", "pi-chat", + "qwen-chat", + "kimi-chat", + "grok-chat", + "copilot-chat", ]); const TRACKED_CLI_PROVIDERS = new Set([ @@ -432,6 +436,10 @@ const TRACKED_CLI_PROVIDERS = new Set([ "droid", "opencode", "pi", + "qwen", + "kimi", + "grok", + "copilot", ]); /** @@ -454,6 +462,10 @@ export function trackedCliTerminalProvider(session: ChatTerminalSession): AdeCod if (toolType.startsWith("opencode")) return "opencode"; if (toolType.startsWith("pi")) return "pi"; if (toolType.startsWith("claude")) return "claude"; + if (toolType.startsWith("qwen")) return "qwen"; + if (toolType.startsWith("kimi")) return "kimi"; + if (toolType.startsWith("grok")) return "grok"; + if (toolType.startsWith("copilot")) return "copilot"; const resumeCommand = typeof session.resumeCommand === "string" ? session.resumeCommand.trim().toLowerCase() : ""; return resumeCommand && /\bclaude\b/.test(resumeCommand) ? "claude" : null; } @@ -508,7 +520,10 @@ export async function signalTerminal( } /** Provider CLIs the TUI can launch as tracked terminal sessions. */ -export type CliTerminalProvider = Extract; +export type CliTerminalProvider = Extract< + AdeCodeProvider, + "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi" | "qwen" | "kimi" | "grok" | "copilot" +>; export type StartCliTerminalSessionResult = { provider: string; @@ -739,7 +754,17 @@ export async function getAvailableModels( // IDs such as `claude-opus-4-6-fast`, not a separate service-tier toggle. // Codex is intentionally NOT here: its tiers come from the app-server, which // loadAvailableModels always queries regardless of activateRuntime. - activateRuntime: provider === "cursor" || provider === "droid" || provider === "pi", + // The four ACP providers are here too, but they never spawn an agent for a + // model list: `activateRuntime` only forces a fresh CLI auth pass, which is + // what gates their curated rows (see loadAvailableModels in + // agentChatService). + activateRuntime: provider === "cursor" + || provider === "droid" + || provider === "pi" + || provider === "qwen" + || provider === "kimi" + || provider === "grok" + || provider === "copilot", ...(provider === "cursor" ? { cursorSource } : {}), }); } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 6755706d45..4c7f1d0fd8 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -2642,6 +2642,11 @@ function loginCommandsForProvider(provider: AdeCodeProvider): ProviderLoginComma if (provider === "codex") return [{ command: "codex", args: ["login"], label: "codex login" }]; if (provider === "opencode") return [{ command: "opencode", args: ["auth", "login"], label: "opencode auth login" }]; if (provider === "pi") return [{ command: "pi", args: [], label: "pi (then /login)" }]; + // 0.22.3 removed `qwen auth`. Sign-in is the OpenAI-compatible key path. + if (provider === "qwen") return [{ command: "qwen", args: ["--auth-type=openai"], label: "qwen --auth-type=openai" }]; + if (provider === "kimi") return [{ command: "kimi", args: ["login"], label: "kimi login" }]; + if (provider === "grok") return [{ command: "grok", args: ["login"], label: "grok login" }]; + if (provider === "copilot") return [{ command: "copilot", args: ["login"], label: "copilot login" }]; return []; } diff --git a/apps/ade-cli/src/tuiClient/closedCliSessions.ts b/apps/ade-cli/src/tuiClient/closedCliSessions.ts index c28d3fd32c..599471bf06 100644 --- a/apps/ade-cli/src/tuiClient/closedCliSessions.ts +++ b/apps/ade-cli/src/tuiClient/closedCliSessions.ts @@ -34,6 +34,10 @@ export function terminalSessionResumeProvider(session: ChatTerminalSession | nul if (toolType.startsWith("opencode")) return "opencode"; if (toolType.startsWith("pi")) return "pi"; if (toolType.startsWith("claude")) return "claude"; + if (toolType.startsWith("qwen")) return "qwen"; + if (toolType.startsWith("kimi")) return "kimi"; + if (toolType.startsWith("grok")) return "grok"; + if (toolType.startsWith("copilot")) return "copilot"; return null; } @@ -50,12 +54,14 @@ export function isTerminalSessionResumable(session: ChatTerminalSession | null | ); } -/** Narrow a terminal session's derived provider to an AgentChatProvider (CLI terminals are always one of the five). */ +/** + * Narrow a terminal session's derived provider to an AgentChatProvider. Every + * `AdeCodeProvider` except the two OpenCode-backed local runtimes (Ollama, + * LM Studio) is one, and those two never back a tracked CLI terminal. + */ function terminalSummaryProvider(session: ChatTerminalSession): AgentChatSessionSummary["provider"] { const provider = terminalSessionProvider(session); - return provider === "codex" || provider === "claude" || provider === "opencode" || provider === "cursor" || provider === "droid" || provider === "pi" - ? provider - : "claude"; + return provider && provider !== "ollama" && provider !== "lmstudio" ? provider : "claude"; } export function terminalSessionToChatSummary( diff --git a/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx index 73f2f8e43a..dcfc20573b 100644 --- a/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx +++ b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx @@ -40,7 +40,20 @@ function truncateEnd(value: string, max: number): string { // a giant plan/diff from blowing past the card; the truncation marker tells the // user there's more. const PREVIEW_MAX_LINES = 12; -const PROVIDER_ACCENT_SOURCES = new Set(["claude", "codex", "cursor", "droid", "opencode", "ollama", "lmstudio"]); +const PROVIDER_ACCENT_SOURCES = new Set([ + "claude", + "codex", + "cursor", + "droid", + "opencode", + "pi", + "qwen", + "kimi", + "grok", + "copilot", + "ollama", + "lmstudio", +]); function previewLines(value: string, max: number): string[] { const rows = value.replace(/\r\n?/g, "\n").split("\n"); diff --git a/apps/ade-cli/src/tuiClient/components/CommandPalette.tsx b/apps/ade-cli/src/tuiClient/components/CommandPalette.tsx index d214d72006..d30807efa9 100644 --- a/apps/ade-cli/src/tuiClient/components/CommandPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/CommandPalette.tsx @@ -24,6 +24,11 @@ const KNOWN_PROVIDERS: ReadonlySet = new Set([ "cursor", "droid", "opencode", + "pi", + "qwen", + "kimi", + "grok", + "copilot", "ollama", "lmstudio", ]); diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx index d988474758..72637ddef7 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -90,6 +90,8 @@ const KIMI_PATHS = [ { d: "M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z", fill: "#FFFFFF" }, ]; const OLLAMA_PATH = "M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"; +const QWEN_PATH = "M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"; +const GITHUB_COPILOT_PATH = "M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"; const LMSTUDIO_PATHS = [ { d: "M2.84 2a1.273 1.273 0 100 2.547h14.107a1.273 1.273 0 100-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H22.04a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h14.106a1.274 1.274 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H15.38a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h14.106a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h9.698a1.273 1.273 0 100-2.547h-9.698z", fill: "rgba(255,255,255,.3)" }, { d: "M2.84 2a1.273 1.273 0 100 2.547h10.287a1.274 1.274 0 000-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H18.22a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H11.56a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h5.78a1.273 1.273 0 100-2.547h-5.78z", fill: "#FFFFFF" }, @@ -120,6 +122,10 @@ const PROVIDER_MARKS: Record = { moonshot: { label: "Kimi", short: "Ki", terminal: "Ki", color: "#F0F0F2", svgPaths: KIMI_PATHS }, moonshotai: { label: "Kimi", short: "Ki", terminal: "Ki", color: "#F0F0F2", svgPaths: KIMI_PATHS }, kimiforcoding: { label: "Kimi", short: "Ki", terminal: "Ki", color: "#F0F0F2", svgPaths: KIMI_PATHS }, + qwen: { label: "Qwen", short: "Qw", terminal: "Qw", color: "#615CED", svgPath: QWEN_PATH }, + copilot: { label: "GitHub Copilot", short: "GH", terminal: "GH", color: "#F0F0F2", iconFill: "#000000", svgPath: GITHUB_COPILOT_PATH }, + githubcopilot: { label: "GitHub Copilot", short: "GH", terminal: "GH", color: "#F0F0F2", iconFill: "#000000", svgPath: GITHUB_COPILOT_PATH }, + github: { label: "GitHub Copilot", short: "GH", terminal: "GH", color: "#F0F0F2", iconFill: "#000000", svgPath: GITHUB_COPILOT_PATH }, ollama: { label: "Ollama", short: "OL", terminal: "◕", color: "#F0F0F2", iconFill: "#000000", svgPath: OLLAMA_PATH }, lmstudio: { label: "LM Studio", short: "LM", terminal: "≋", color: "#8B5CF6", svgPaths: LMSTUDIO_PATHS }, }; @@ -140,6 +146,10 @@ const ROW_MARKS: Record = { moonshot: PROVIDER_MARKS.moonshot!, moonshotai: PROVIDER_MARKS.moonshotai!, kimiforcoding: PROVIDER_MARKS.kimiforcoding!, + qwen: PROVIDER_MARKS.qwen!, + copilot: PROVIDER_MARKS.copilot!, + githubcopilot: PROVIDER_MARKS.githubcopilot!, + github: PROVIDER_MARKS.github!, openrouter: PROVIDER_MARKS.openrouter!, opencode: PROVIDER_MARKS.opencode!, pi: PROVIDER_MARKS.pi!, diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts index 92a3d351b8..7d43c6f5d3 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts @@ -40,6 +40,23 @@ describe("buildModelPickerLayout", () => { expect(layout.railEntries[0]?.kind).toBe("favorites"); expect(layout.railEntries[1]?.kind).toBe("recents"); expect(layout.railEntries.some((entry) => entry.kind === "provider")).toBe(true); + expect(layout.railEntries + .filter((entry) => entry.kind === "provider") + .map((entry) => entry.kind === "provider" ? entry.provider : null)) + .toEqual([ + "claude", + "codex", + "cursor", + "opencode", + "pi", + "copilot", + "grok", + "droid", + "kimi", + "qwen", + "ollama", + "lmstudio", + ]); const piRail = layout.railEntries.find((entry) => entry.kind === "provider" && entry.provider === "pi"); expect(piRail?.kind).toBe("provider"); if (piRail?.kind === "provider") { @@ -116,6 +133,89 @@ describe("buildModelPickerLayout", () => { expect(modelPickerProviderAuthStatus(status, "pi", "cli")).toBe("ready"); }); + it("keeps a rail for every ACP provider so /model can offer them", () => { + const layout = buildModelPickerLayout({ + models, + favorites: [], + recents: [], + activeModelId: null, + query: "", + selection: { kind: "favorites" }, + focusedIndex: 0, + searchMode: false, + }); + const rails = layout.railEntries + .filter((entry) => entry.kind === "provider") + .map((entry) => (entry.kind === "provider" ? entry.provider : null)); + for (const provider of ["qwen", "kimi", "grok", "copilot"] as const) { + expect(rails).toContain(provider); + } + }); + + it("grades ACP provider auth from the host's optional status slots", () => { + // Nothing probed: not "signed out", just not known yet. + expect(modelPickerProviderAuthStatus({} as AiSettingsStatus, "qwen", "chat")).toBe("unknown"); + + const connected = { + providerConnections: { grok: { authAvailable: true, runtimeAvailable: true } }, + } as unknown as AiSettingsStatus; + expect(modelPickerProviderAuthStatus(connected, "grok", "chat")).toBe("ready"); + + const probedSignedOut = { + providerConnections: { kimi: { authAvailable: false, runtimeAvailable: false } }, + } as unknown as AiSettingsStatus; + expect(modelPickerProviderAuthStatus(probedSignedOut, "kimi", "chat")).toBe("unavailable"); + + const flagged = { availableProviders: { copilot: true } } as unknown as AiSettingsStatus; + expect(modelPickerProviderAuthStatus(flagged, "copilot", "chat")).toBe("ready"); + }); + + it("files ACP catalog groups on their own rail instead of falling back to Codex", () => { + const catalog = { + groups: [ + { + key: "qwen", + label: "Qwen", + providers: [ + { + key: "qwen", + displayName: "Qwen", + subsections: [ + { + key: "qwen", + label: "Qwen", + models: [ + { + id: "qwen/qwen3-coder-plus", + displayName: "Qwen3 Coder Plus", + groupKey: "qwen", + family: "qwen", + isAvailable: true, + }, + ], + }, + ], + }, + ], + }, + ], + } as unknown as AgentChatModelCatalog; + + const layout = buildModelPickerLayout({ + models: [], + catalog, + favorites: [], + recents: [], + activeModelId: null, + query: "", + selection: { kind: "provider", provider: "qwen" }, + focusedIndex: 0, + searchMode: false, + }); + expect(layout.entries.map((entry) => entry.modelId)).toEqual(["qwen/qwen3-coder-plus"]); + expect(layout.entries[0]?.family).toBe("qwen"); + }); + it("shows static Anthropic rows immediately before the runtime catalog warms", () => { const layout = buildModelPickerLayout({ models: [modelInfo({ id: "openai/gpt-5", displayName: "GPT-5" })], @@ -433,6 +533,10 @@ describe("modelPickerController", () => { expect(modelPickerRefreshProvider("lmstudio")).toBe("lmstudio"); expect(modelPickerRefreshProvider("ollama")).toBe("ollama"); expect(modelPickerRefreshProvider("pi")).toBe("pi"); + expect(modelPickerRefreshProvider("qwen")).toBe("qwen"); + expect(modelPickerRefreshProvider("kimi")).toBe("kimi"); + expect(modelPickerRefreshProvider("grok")).toBe("grok"); + expect(modelPickerRefreshProvider("copilot")).toBe("copilot"); expect(modelPickerRefreshProvider("codex")).toBeNull(); expect(modelPickerRefreshProvider("claude")).toBeNull(); }); diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts index 9706412378..35b87c1366 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts @@ -1,3 +1,7 @@ +import { + MODEL_PICKER_PROVIDER_ORDER, + type ProviderGroupKey, +} from "../../../../../desktop/src/shared/modelCatalog"; import { scoreModelPickerSearch } from "../../../../../desktop/src/renderer/components/shared/ModelPicker/modelPickerSearch"; import { sortModelItems } from "../../../../../desktop/src/renderer/components/shared/ModelPicker/modelOrdering"; import type { AgentChatModelCatalog, AgentChatModelCatalogRefreshProvider, AgentChatModelInfo } from "../../../../../desktop/src/shared/types/chat"; @@ -21,18 +25,26 @@ import type { import type { SetupPaneRow, SetupPaneRowKind } from "../../types"; import { normalizeProvider, providerFamilyLabel as providerLabel, titleCaseProviderName } from "../../providerMetadata"; -export const PROVIDER_ORDER: readonly AdeCodeProvider[] = [ - "claude", - "codex", - "droid", - "cursor", - "opencode", - "pi", - "ollama", - "lmstudio", -]; +export const PROVIDER_ORDER: readonly AdeCodeProvider[] = MODEL_PICKER_PROVIDER_ORDER; const RAIL_PROVIDER_ORDER: readonly AdeCodeProvider[] = PROVIDER_ORDER; + +/** + * The four ACP providers. They report through the same optional + * `availableProviders` / `providerConnections` / `models` slots, so one list + * drives the greying arm instead of four copies of it. Mirrors + * `ACP_PICKER_FAMILIES` in desktop's useProviderAuthStatus. + */ +const ACP_PROVIDERS: readonly Extract[] = [ + "qwen", + "kimi", + "grok", + "copilot", +]; + +function isAcpProvider(provider: AdeCodeProvider): provider is (typeof ACP_PROVIDERS)[number] { + return (ACP_PROVIDERS as readonly AdeCodeProvider[]).includes(provider); +} const STATIC_REGISTRY_FALLBACK_PROVIDERS: readonly ModelProviderGroup[] = ["claude", "codex"]; function openCodeProviderLabel(providerId: string): string { @@ -46,7 +58,7 @@ function providerSignInHint(provider: AdeCodeProvider): string { function providerModelsCount(status: AiSettingsStatus | null | undefined, provider: AdeCodeProvider): number { if (!status) return 0; - if (provider === "claude" || provider === "codex" || provider === "cursor" || provider === "droid") { + if (provider === "claude" || provider === "codex" || provider === "cursor" || provider === "droid" || isAcpProvider(provider)) { return status.models?.[provider]?.length ?? 0; } if (provider === "pi") return status.piInstallation?.availableModelIds.length ?? 0; @@ -105,6 +117,22 @@ export function modelPickerProviderAuthStatus( if (connection || status.piInstallation) return "unavailable"; return "unknown"; } + if (isAcpProvider(provider)) { + // CLI-backed: "ready" means the host found both the binary and a credential + // for it. A provider the host has not probed (or does not know about, e.g. + // an older machine across a remote connection) stays "unknown" so the rail + // does not claim the user is signed out. + const connection = status.providerConnections?.[provider]; + if ( + status.availableProviders?.[provider] === true + || connection?.authAvailable + || connection?.runtimeAvailable + || providerModelsCount(status, provider) > 0 + ) { + return "ready"; + } + return connection ? "unavailable" : "unknown"; + } if (provider === "opencode") { if ((status.opencodeProviders ?? []).some((entry) => entry.connected) || status.opencodeBinaryInstalled === true) return "ready"; if (status.opencodeBinaryInstalled === false || status.opencodeInventoryError) return "unavailable"; @@ -123,12 +151,31 @@ export function modelPickerProviderAuthStatus( return "unknown"; } +/** + * Every catalog group key the host can publish, mapped to the rail that owns + * it. Exhaustive over `ProviderGroupKey` on purpose: a new group added to the + * shared catalog is a compile error here rather than a rail of models that + * quietly files itself under Codex. + */ +const PROVIDER_BY_CATALOG_GROUP: Record = { + claude: "claude", + codex: "codex", + cursor: "cursor", + droid: "droid", + pi: "pi", + qwen: "qwen", + kimi: "kimi", + grok: "grok", + copilot: "copilot", + opencode: "opencode", + ollama: "ollama", + lmstudio: "lmstudio", +}; + function providerFromCatalogGroup(groupKey: string, fallbackFamily?: string): AdeCodeProvider { const normalized = groupKey.trim().toLowerCase(); - if (normalized === "claude" || normalized === "codex" || normalized === "opencode" || normalized === "cursor" || normalized === "droid" || normalized === "pi") { - return normalized; - } - if (normalized === "ollama" || normalized === "lmstudio") return normalized; + const known = PROVIDER_BY_CATALOG_GROUP[normalized as ProviderGroupKey]; + if (known) return known; return normalizeProvider(fallbackFamily ?? normalized); } @@ -162,14 +209,16 @@ function entriesFromCatalog( ? subsection.label || model.providerName || provider.displayName || providerLabel(family) : family === "cursor" || family === "droid" ? subsection.label || model.providerName || provider.displayName || undefined - : family === "claude" || family === "codex" + : family === "claude" || family === "codex" || isAcpProvider(family) + // Single-vendor rails: one tab named after the provider, rather + // than a sub-tab per catalog subsection label. ? providerLabel(family) : model.providerName || provider.displayName || subsection.label || undefined; const catalogSubProviderKey = family === "pi" ? subsection.key || model.providerId || provider.key || family : family === "cursor" || family === "droid" ? subsection.key || model.providerId || provider.key || undefined - : family === "claude" || family === "codex" + : family === "claude" || family === "codex" || isAcpProvider(family) ? family : model.providerId || provider.key || subsection.key || undefined; entries.push({ diff --git a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx index 87e7ae9d7c..8b0abe6ea5 100644 --- a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx @@ -51,6 +51,10 @@ const PROVIDER_LABELS: Record = { droid: "Droid", opencode: "OpenCode", pi: "Pi", + qwen: "Qwen", + kimi: "Kimi", + grok: "Grok", + copilot: "GitHub Copilot", }; function providerLabel(provider?: AgentChatProvider | null): string { diff --git a/apps/ade-cli/src/tuiClient/modelState.ts b/apps/ade-cli/src/tuiClient/modelState.ts index 4c1509d406..128c616188 100644 --- a/apps/ade-cli/src/tuiClient/modelState.ts +++ b/apps/ade-cli/src/tuiClient/modelState.ts @@ -160,9 +160,7 @@ export function usesToolPermissionModes(provider: AdeCodeProvider): boolean { * Interface=CLI launch path. */ export function cliProviderForModelStateProvider(provider: AdeCodeProvider): CliTerminalProvider | null { - return provider === "claude" || provider === "codex" || provider === "cursor" || provider === "droid" || provider === "opencode" || provider === "pi" - ? provider - : null; + return provider === "ollama" || provider === "lmstudio" ? null : provider; } export function modelStatePatchForModel(provider: AdeCodeProvider, model: AgentChatModelInfo): Pick { diff --git a/apps/ade-cli/src/tuiClient/providerMetadata.ts b/apps/ade-cli/src/tuiClient/providerMetadata.ts index 02d381a920..57999cfbbc 100644 --- a/apps/ade-cli/src/tuiClient/providerMetadata.ts +++ b/apps/ade-cli/src/tuiClient/providerMetadata.ts @@ -1,17 +1,25 @@ import type { ProviderFamily } from "../../../desktop/src/shared/modelRegistry"; +import { MODEL_PICKER_PROVIDER_ORDER } from "../../../desktop/src/shared/modelCatalog"; import type { AgentChatModelCatalogRefreshProvider } from "../../../desktop/src/shared/types/chat"; import type { AdeCodeProvider } from "./types"; -export const TUI_PROVIDER_OPTIONS: Array<{ value: AdeCodeProvider; label: string }> = [ - { value: "claude", label: "Claude" }, - { value: "codex", label: "Codex" }, - { value: "cursor", label: "Cursor" }, - { value: "droid", label: "Droid" }, - { value: "opencode", label: "OpenCode" }, - { value: "pi", label: "Pi" }, - { value: "ollama", label: "Ollama" }, - { value: "lmstudio", label: "LM Studio" }, -]; +const TUI_PROVIDER_LABELS: Record = { + claude: "Claude", + codex: "Codex", + cursor: "Cursor", + opencode: "OpenCode", + pi: "Pi", + copilot: "GitHub Copilot", + grok: "Grok", + droid: "Droid", + kimi: "Kimi", + qwen: "Qwen", + ollama: "Ollama", + lmstudio: "LM Studio", +}; + +export const TUI_PROVIDER_OPTIONS: Array<{ value: AdeCodeProvider; label: string }> = + MODEL_PICKER_PROVIDER_ORDER.map((value) => ({ value, label: TUI_PROVIDER_LABELS[value] })); export const TUI_PROVIDERS = new Set(TUI_PROVIDER_OPTIONS.map((provider) => provider.value)); @@ -22,6 +30,10 @@ const PROVIDER_FAMILY_LABELS: Record = { cursor: "Cursor", droid: "Droid", pi: "Pi", + qwen: "Qwen", + kimi: "Moonshot", + grok: "xAI", + copilot: "GitHub Copilot", ollama: "Ollama", lmstudio: "LM Studio", }; @@ -45,6 +57,10 @@ export const PROVIDER_TOKEN_LABELS: Record = { droid: "Droid", factory: "Droid", cursor: "Cursor", + qwen: "Qwen", + copilot: "GitHub Copilot", + githubcopilot: "GitHub Copilot", + github: "GitHub Copilot", kimi: "Kimi", moonshot: "Kimi", // Canonical opencode catalog ids for the Kimi/Moonshot brand. Keys are the @@ -64,11 +80,27 @@ export function providerFamilyLabel(provider: AdeCodeProvider): string { return PROVIDER_FAMILY_LABELS[provider] ?? provider; } +/** + * `ProviderFamily` values that name a brand rather than an ADE provider, and + * the TUI provider that hosts them. Only consulted when the caller has no + * provider group (a bare model family), so an OpenCode-hosted model still + * resolves through its group key first. + */ +const PROVIDER_FAMILY_ALIASES: Record = { + anthropic: "claude", + openai: "codex", + factory: "droid", + moonshot: "kimi", + moonshotai: "kimi", + xai: "grok", + "github-copilot": "copilot", + githubcopilot: "copilot", +}; + export function normalizeProvider(value: ProviderFamily | string | null | undefined): AdeCodeProvider { const normalized = (value ?? "").trim().toLowerCase(); - if (normalized === "anthropic") return "claude"; - if (normalized === "openai") return "codex"; - if (normalized === "factory") return "droid"; + const alias = PROVIDER_FAMILY_ALIASES[normalized]; + if (alias) return alias; return TUI_PROVIDERS.has(normalized as AdeCodeProvider) ? normalized as AdeCodeProvider : "codex"; } @@ -93,8 +125,26 @@ export function titleCaseProviderName(value: string): string { .replace(/\bAi\b/g, "AI"); } +/** + * Providers whose model list is discovered at runtime, so /model can offer a + * refresh. Exhaustive over `AdeCodeProvider`: a new provider is a compile error + * here rather than a silently un-refreshable rail. + */ +const REFRESH_PROVIDERS: Record = { + claude: null, + codex: null, + cursor: "cursor", + droid: "droid", + opencode: "opencode", + pi: "pi", + qwen: "qwen", + kimi: "kimi", + grok: "grok", + copilot: "copilot", + ollama: "ollama", + lmstudio: "lmstudio", +}; + export function refreshProviderForModelPicker(provider: AdeCodeProvider): AgentChatModelCatalogRefreshProvider | null { - return provider === "opencode" || provider === "pi" || provider === "cursor" || provider === "droid" || provider === "lmstudio" || provider === "ollama" - ? provider - : null; + return REFRESH_PROVIDERS[provider] ?? null; } diff --git a/apps/ade-cli/src/tuiClient/remoteLauncher.ts b/apps/ade-cli/src/tuiClient/remoteLauncher.ts index fc3dc3fa56..fffc6ea0ed 100644 --- a/apps/ade-cli/src/tuiClient/remoteLauncher.ts +++ b/apps/ade-cli/src/tuiClient/remoteLauncher.ts @@ -990,24 +990,54 @@ function terminalToChoice(session: ChatTerminalSession): RemoteSessionChoice { }; } -const TRACKED_CLI_REMOTE_PROVIDERS = new Set(["claude", "codex", "cursor", "droid", "opencode", "pi"]); +const TRACKED_CLI_REMOTE_PROVIDERS = new Set([ + "claude", + "codex", + "cursor", + "droid", + "opencode", + "pi", + "qwen", + "kimi", + "grok", + "copilot", +]); + +const CHAT_BACKED_REMOTE_TOOL_TYPES = new Set([ + "codex-chat", + "claude-chat", + "opencode-chat", + "cursor", + "droid-chat", + "pi-chat", + "qwen-chat", + "kimi-chat", + "grok-chat", + "copilot-chat", +]); + +const TRACKED_CLI_REMOTE_TOOL_TYPE_PREFIXES = [ + "codex", + "cursor", + "droid", + "opencode", + "pi", + "claude", + "qwen", + "kimi", + "grok", + "copilot", +] as const; function isTerminalSessionLaunchable(session: ChatTerminalSession): boolean { const toolType = session.toolType ?? ""; // Chat-backed terminals surface through the chat session list instead. - if (toolType === "codex-chat" || toolType === "claude-chat" || toolType === "opencode-chat" || toolType === "cursor" || toolType === "droid-chat" || toolType === "pi-chat") { + if (CHAT_BACKED_REMOTE_TOOL_TYPES.has(toolType)) { return false; } - // Any tracked provider CLI (claude/codex/cursor-cli/droid/opencode) is - // launchable — mirrors trackedCliTerminalProvider in adeApi.ts. - if ( - toolType.startsWith("codex") - || toolType.startsWith("cursor") - || toolType.startsWith("droid") - || toolType.startsWith("opencode") - || toolType.startsWith("pi") - || toolType.startsWith("claude") - ) { + // Any tracked provider CLI is launchable — mirrors + // trackedCliTerminalProvider in adeApi.ts. + if (TRACKED_CLI_REMOTE_TOOL_TYPE_PREFIXES.some((prefix) => toolType.startsWith(prefix))) { return true; } const provider = isRecord(session.resumeMetadata) ? session.resumeMetadata.provider : null; diff --git a/apps/ade-cli/src/tuiClient/theme.ts b/apps/ade-cli/src/tuiClient/theme.ts index 33bb133103..ad162651f7 100644 --- a/apps/ade-cli/src/tuiClient/theme.ts +++ b/apps/ade-cli/src/tuiClient/theme.ts @@ -58,6 +58,9 @@ const OLLAMA = "#F0F0F2"; const LMSTUDIO = "#8B5CF6"; const SHELL = "#F59E0B"; const COPILOT = "#A855F7"; +const QWEN = "#6D4AFF"; +const KIMI = "#F0F0F2"; +const GROK = "#DC2626"; const TOOL = "cyan"; const REASONING = T4; @@ -99,6 +102,10 @@ const PROVIDER_THEME: Record = { droid: { glyph: "✺", wordmark: "Droid", color: DROID, label: "Droid" }, opencode: { glyph: "▣", wordmark: "OpenCode", color: OPENCODE, label: "OpenCode" }, pi: { glyph: "◈", wordmark: "Pi", color: PI, label: "Pi" }, + qwen: { glyph: "◇", wordmark: "Qwen", color: QWEN, label: "Qwen" }, + kimi: { glyph: "◐", wordmark: "Kimi", color: KIMI, label: "Kimi" }, + grok: { glyph: "✧", wordmark: "Grok", color: GROK, label: "Grok" }, + copilot: { glyph: "⌬", wordmark: "Copilot", color: COPILOT, label: "GitHub Copilot" }, ollama: { glyph: "◕", wordmark: "Ollama", color: OLLAMA, label: "Ollama" }, lmstudio: { glyph: "≋", wordmark: "LM Studio", color: LMSTUDIO, label: "LM Studio" }, }; diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 411eba5f27..89de596a26 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -99,7 +99,13 @@ export type AdeCodeConnection = { close(): Promise; }; -export type AdeCodeProvider = Extract | "ollama" | "lmstudio"; +export type AdeCodeProvider = + | Extract< + AgentChatProvider, + "codex" | "claude" | "opencode" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot" + > + | "ollama" + | "lmstudio"; /** * How a new chat draft is launched. `chat` creates an SDK chat via diff --git a/apps/desktop/src/main/services/ai/acpAuthProbe.test.ts b/apps/desktop/src/main/services/ai/acpAuthProbe.test.ts new file mode 100644 index 0000000000..b9fe3ddff8 --- /dev/null +++ b/apps/desktop/src/main/services/ai/acpAuthProbe.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { AcpRpcError } from "../chat/acpHost/acpConnection"; +import { createMockAcpAgent } from "../chat/acpHost/mockAcpAgent"; +import { isAcpAuthError, probeAcpProviderAuth, resetAcpAuthProbeCache } from "./acpAuthProbe"; + +describe("isAcpAuthError", () => { + it("matches the live Qwen 0.22.3 session/new message", () => { + expect( + isAcpAuthError("Authentication required: Use Qwen Code CLI to authenticate first."), + ).toBe(true); + expect( + isAcpAuthError("ACP session/new failed (-32000): Authentication required: Use Qwen Code CLI to authenticate first."), + ).toBe(true); + }); + + it("matches the live Kimi 0.39.1 session/new message", () => { + expect(isAcpAuthError("Authentication required")).toBe(true); + expect(isAcpAuthError("ACP session/new failed (-32000): Authentication required")).toBe(true); + }); + + it("reads Qwen authenticate's missing-key details off AcpRpcError.data", () => { + const error = new AcpRpcError("authenticate", { + code: -32603, + message: "Internal error", + data: { + details: + "Missing API key for openai auth. Current model: 'coder-model', baseUrl: '(default)'. Provide an API key via settings (security.auth.apiKey), or set the environment variable 'OPENAI_API_KEY'.", + }, + }); + expect(isAcpAuthError(error)).toBe(true); + expect(isAcpAuthError(new AcpRpcError("authenticate", { code: -32603, message: "Internal error" }))).toBe(false); + }); + + it("does not treat a generic crash as a sign-in problem", () => { + expect(isAcpAuthError("ACP session/prompt failed (-32603): boom")).toBe(false); + expect(isAcpAuthError("")).toBe(false); + }); +}); + +describe("probeAcpProviderAuth", () => { + afterEach(() => { + resetAcpAuthProbeCache(); + }); + + it("treats a successful session/new as signed in without calling authenticate", async () => { + const agent = createMockAcpAgent({ authMethods: [{ id: "openai", name: "OpenAI" }] }); + agent.on("session/new", () => ({ result: { sessionId: "s1" } })); + agent.on("authenticate", () => ({ + error: { + code: -32603, + message: "Internal error", + data: { details: "Missing API key" }, + }, + })); + const result = await probeAcpProviderAuth({ + provider: "qwen", + cwd: "/lane", + force: true, + spawnOverride: () => agent.child, + }); + expect(result).toEqual({ state: "ready", message: null }); + expect(agent.methodsReceived()).toContain("session/new"); + expect(agent.methodsReceived()).not.toContain("authenticate"); + }); + + it("does not hang on a terminal login method after session/new is refused", async () => { + const agent = createMockAcpAgent({ + authMethods: [{ id: "login", name: "Login", type: "terminal" }], + }); + agent.on("session/new", () => ({ + error: { code: -32000, message: "Authentication required" }, + })); + const result = await probeAcpProviderAuth({ + provider: "kimi", + cwd: "/lane", + force: true, + spawnOverride: () => agent.child, + }); + expect(result.state).toBe("auth-failed"); + expect(agent.methodsReceived()).not.toContain("authenticate"); + }); +}); diff --git a/apps/desktop/src/main/services/ai/acpAuthProbe.ts b/apps/desktop/src/main/services/ai/acpAuthProbe.ts new file mode 100644 index 0000000000..3860ad09d7 --- /dev/null +++ b/apps/desktop/src/main/services/ai/acpAuthProbe.ts @@ -0,0 +1,359 @@ +/** + * Protocol-level auth probe for the four ACP providers. + * + * `authDetector` can only read a credential file off disk, so it reports + * `verified: false` and says so. This module asks the agent itself: it spawns + * the CLI with the dialect's own argv, runs `initialize`, then `session/new`. + * A successful session is the proof. `authenticate` is only a fallback, and + * only for methods that are not a TTY login — Qwen's `openai` method fails + * that RPC when the key already lives in settings.json. + * + * ## Where this may run + * + * NEVER on the catalog read path. Spawning four CLIs to answer "which models + * exist" is exactly the mistake `claudeRuntimeProbe` exists to avoid, and it is + * why `getAvailableModels` reads `detectCliAuthStatuses({ skipAuthProbe: true })` + * instead. Call `probeAcpProviderAuth` from a force refresh, from a settings + * diagnostics action, or from the chat runtime after a real failure. + * + * Results are cached per `{provider, cwd}` with the same four beats as + * `claudeRuntimeProbe`: TTL short-circuit that still republishes health, + * in-flight dedupe unless forced, cache written on every exit path, in-flight + * entry cleared in `finally`. + */ + +import type { AcpChatProvider } from "../../../shared/types/chat"; +import { + AcpRpcError, + createAcpConnection, + initializeAcpConnection, +} from "../chat/acpHost/acpConnection"; +import { acpDialectFor } from "../chat/acpHost/acpDialects"; +import { ACP_METHOD } from "../chat/acpHost/acpProtocolTypes"; +import type { Logger } from "../logging/logger"; +import { + copilotConfigHome, + kimiCodeConfigHome, + qwenConfigHome, +} from "../shared/providerConfigHomes"; +import { resolveAcpExecutable } from "./acpExecutables"; +import { + reportProviderRuntimeAuthFailure, + reportProviderRuntimeFailure, + reportProviderRuntimeReady, +} from "./providerRuntimeHealth"; + +const PROBE_TIMEOUT_MS = 15_000; +const PROBE_CACHE_TTL_MS = 60_000; + +export type AcpAuthProbeResult = + | { state: "ready"; message: null } + | { state: "auth-failed"; message: string } + | { state: "runtime-failed"; message: string }; + +type CacheKey = string; + +const probeCache = new Map(); +const inFlightProbes = new Map>(); + +function cacheKey(provider: AcpChatProvider, cwd: string): CacheKey { + return `${provider}:${cwd}`; +} + +/** + * Config home to export for the probe. + * + * Grok is absent on purpose: it reads `~/.grok` and honors no override, so ADE + * must not invent one. See `providerConfigHomes.grokConfigHome`. + */ +export function acpProbeConfigHome( + provider: AcpChatProvider, + env: NodeJS.ProcessEnv = process.env, +): string | null { + switch (provider) { + case "qwen": + return qwenConfigHome({ env }); + case "kimi": + return kimiCodeConfigHome({ env }); + case "copilot": + return copilotConfigHome({ env }); + case "grok": + return null; + } +} + +/** + * True when a JSON-RPC failure reads as "you are not signed in" rather than + * "this agent is broken". + * + * ACP has no dedicated auth error code, so the message is the only signal. The + * list stays broad: a false "sign in" is a recoverable instruction, while a + * false "runtime broken" sends the user hunting a machine problem they do not + * have. + */ +export function isAcpAuthError(input: unknown): boolean { + const pieces: string[] = []; + if (input instanceof Error) { + pieces.push(input.message); + if ("data" in input && (input as { data?: unknown }).data != null) { + try { + pieces.push(JSON.stringify((input as { data: unknown }).data)); + } catch { + pieces.push(String((input as { data: unknown }).data)); + } + } + } else { + pieces.push(String(input ?? "")); + } + const text = pieces.join(" ").toLowerCase(); + if (!text.trim().length) return false; + return ( + text.includes("auth_required") + || text.includes("authentication required") + || text.includes("authentication_required") + || text.includes("not authenticated") + || text.includes("not logged in") + || text.includes("unauthenticated") + || text.includes("unauthorized") + || text.includes("login required") + || text.includes("please log in") + || text.includes("please sign in") + || text.includes("sign in") + || text.includes("invalid api key") + || text.includes("missing api key") + || text.includes("invalid credentials") + || text.includes("no credentials") + || text.includes("401") + || text.includes("403") + ); +} + +function loginBlocker(provider: AcpChatProvider): string { + const dialect = acpDialectFor(provider); + const keys = dialect.authProbe.apiKeyEnvVars; + const keyHint = keys.length ? ` or set ${keys.join(" / ")}` : ""; + return `${dialect.displayName} is installed but not signed in. Run \`${dialect.authProbe.loginCommand}\` in a terminal${keyHint}, then refresh AI settings.`; +} + +function publishResult(provider: AcpChatProvider, result: AcpAuthProbeResult): void { + switch (result.state) { + case "ready": + reportProviderRuntimeReady(provider); + return; + case "auth-failed": + reportProviderRuntimeAuthFailure(provider, result.message); + return; + case "runtime-failed": + reportProviderRuntimeFailure(provider, result.message); + return; + } +} + +export function resetAcpAuthProbeCache(): void { + probeCache.clear(); +} + +/** Read a cached verdict without spawning anything. */ +export function getCachedAcpAuthProbe( + provider: AcpChatProvider, + cwd: string, +): AcpAuthProbeResult | null { + return probeCache.get(cacheKey(provider, cwd))?.result ?? null; +} + +/** + * Record a verdict the chat runtime learned from a real session. + * + * A live session that opened is stronger evidence than any probe, and a live + * session that failed to authenticate should not need a second spawn to say so. + */ +export function recordAcpAuthProbeResult( + provider: AcpChatProvider, + cwd: string, + result: AcpAuthProbeResult, +): void { + probeCache.set(cacheKey(provider, cwd), { checkedAtMs: Date.now(), result }); + publishResult(provider, result); +} + +export type ProbeAcpProviderAuthArgs = { + provider: AcpChatProvider; + /** Working directory to spawn in. A lane worktree, or the project root. */ + cwd: string; + env?: NodeJS.ProcessEnv; + logger?: Pick; + force?: boolean; + /** Test seam, forwarded to `createAcpConnection`. */ + spawnOverride?: Parameters[0]["spawnOverride"]; +}; + +/** + * Ask one ACP agent whether it can authenticate. + * + * The probe never sends a prompt: `initialize` proves the binary runs and + * speaks the protocol, and `session/new` proves the credential is live. + * `authenticate` is a fallback for agents that still need it after session/new + * fails as auth. An agent that answers `-32601` to `authenticate` in that + * fallback is still unsigned-in, because session/new already failed. + */ +export async function probeAcpProviderAuth( + args: ProbeAcpProviderAuthArgs, +): Promise { + const { provider, cwd } = args; + const key = cacheKey(provider, cwd); + const now = Date.now(); + + const cached = probeCache.get(key); + if (!args.force && cached && now - cached.checkedAtMs < PROBE_CACHE_TTL_MS) { + publishResult(provider, cached.result); + return cached.result; + } + + const existing = inFlightProbes.get(key); + if (!args.force && existing) { + const result = await existing; + publishResult(provider, result); + return result; + } + + const probe = (async (): Promise => { + const dialect = acpDialectFor(provider); + const baseEnv = args.env ?? process.env; + const executable = resolveAcpExecutable(provider, { env: baseEnv }); + const spawnPlan = dialect.buildSpawnPlan({ + binaryPath: executable.path, + cwd, + baseEnv, + configHome: acpProbeConfigHome(provider, baseEnv), + }); + + const connection = createAcpConnection({ + dialect, + spawnPlan, + ...(args.spawnOverride ? { spawnOverride: args.spawnOverride } : {}), + }); + try { + const { response } = await initializeAcpConnection({ + connection, + dialect, + timeoutMs: PROBE_TIMEOUT_MS, + }); + + const advertised = response.authMethods ?? []; + const methodId = dialect.authProbe.methodId ?? advertised[0]?.id ?? null; + + // `session/new` is the real gate. Qwen 0.22.3 advertises `openai` and + // answers `authenticate` with "Missing API key" even when the key + // already lives in settings.json — that RPC is how you *submit* a key, + // not how you prove one is present. A successful session/new is enough. + try { + await connection.request( + ACP_METHOD.sessionNew, + { cwd, mcpServers: [] }, + { timeoutMs: PROBE_TIMEOUT_MS }, + ); + return { state: "ready", message: null }; + } catch (error) { + if (!isAcpAuthError(error)) { + return { + state: "runtime-failed", + message: error instanceof Error ? error.message : String(error), + }; + } + } + + if (!methodId) { + return { state: "auth-failed", message: loginBlocker(provider) }; + } + // A `type: "terminal"` method means the agent wants to run its own login + // command in a TTY. Calling it headlessly would hang, so treat the fact + // that the agent is still offering it as "not signed in". + const chosen = advertised.find((method) => method.id === methodId) ?? null; + if (chosen?.type === "terminal") { + return { state: "auth-failed", message: loginBlocker(provider) }; + } + + try { + await connection.request(ACP_METHOD.authenticate, { methodId }, { timeoutMs: PROBE_TIMEOUT_MS }); + return { state: "ready", message: null }; + } catch (error) { + if (error instanceof AcpRpcError && error.isMethodNotFound) { + // No `authenticate` on this agent. session/new already failed as + // auth, so this is still a sign-in problem. + return { state: "auth-failed", message: loginBlocker(provider) }; + } + if (isAcpAuthError(error)) { + return { state: "auth-failed", message: loginBlocker(provider) }; + } + return { + state: "runtime-failed", + message: error instanceof Error ? error.message : String(error), + }; + } + } catch (error) { + if (isAcpAuthError(error)) { + return { state: "auth-failed", message: loginBlocker(provider) }; + } + const detail = error instanceof Error ? error.message : String(error); + return { + state: "runtime-failed", + message: executable.source === "fallback-command" + ? `The ${dialect.displayName} CLI (\`${dialect.binaryNames[0]}\`) was not found on this machine.` + : `${dialect.displayName} was detected at ${executable.path}, but ADE could not start it: ${detail}`, + }; + } finally { + connection.dispose("auth probe finished"); + } + })().then((result) => { + probeCache.set(key, { checkedAtMs: Date.now(), result }); + return result; + }); + + inFlightProbes.set(key, probe); + try { + const result = await probe; + publishResult(provider, result); + if (result.state === "ready") { + args.logger?.info?.("ai.acp_auth_probe.ready", { provider, cwd }); + } else { + args.logger?.warn?.("ai.acp_auth_probe.failed", { + provider, + cwd, + state: result.state, + message: result.message, + }); + } + return result; + } finally { + inFlightProbes.delete(key); + } +} + +/** Probe every ACP provider that is installed. Used by the force-refresh path. */ +export async function probeAllAcpProviderAuth(args: { + providers: readonly AcpChatProvider[]; + cwd: string; + env?: NodeJS.ProcessEnv; + logger?: Pick; + force?: boolean; +}): Promise>> { + const entries = await Promise.all( + args.providers.map(async (provider) => { + try { + return [provider, await probeAcpProviderAuth({ ...args, provider })] as const; + } catch (error) { + // A probe must never take the refresh down with it. + args.logger?.warn?.("ai.acp_auth_probe.threw", { + provider, + error: error instanceof Error ? error.message : String(error), + }); + return [provider, null] as const; + } + }), + ); + const out: Partial> = {}; + for (const [provider, result] of entries) { + if (result) out[provider] = result; + } + return out; +} diff --git a/apps/desktop/src/main/services/ai/acpExecutables.test.ts b/apps/desktop/src/main/services/ai/acpExecutables.test.ts new file mode 100644 index 0000000000..8edafe50e0 --- /dev/null +++ b/apps/desktop/src/main/services/ai/acpExecutables.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import { checkKimiWindowsPrerequisites, resolveAcpExecutable } from "./acpExecutables"; + +describe("resolveAcpExecutable", () => { + it("prefers an explicit env override over PATH", () => { + const resolved = resolveAcpExecutable("grok", { + env: { GROK_EXECUTABLE: "/opt/custom/grok", PATH: "/usr/bin" }, + }); + expect(resolved).toEqual({ path: "/opt/custom/grok", source: "env" }); + }); + + it("falls back to the bare command when nothing is installed", () => { + vi.spyOn(fs, "statSync").mockImplementation(() => { + const err: NodeJS.ErrnoException = new Error("ENOENT"); + err.code = "ENOENT"; + throw err; + }); + try { + const resolved = resolveAcpExecutable("qwen", { env: { PATH: "", HOME: "/no-such-ade-home" } }); + expect(resolved).toEqual({ path: "qwen", source: "fallback-command" }); + } finally { + vi.restoreAllMocks(); + } + }); +}); + +describe("checkKimiWindowsPrerequisites", () => { + it("is a no-op off Windows", () => { + expect(checkKimiWindowsPrerequisites({ platform: "darwin" })).toEqual({ ok: true }); + expect(checkKimiWindowsPrerequisites({ platform: "linux" })).toEqual({ ok: true }); + }); + + it("passes when Git Bash is in a well-known Program Files path", () => { + const result = checkKimiWindowsPrerequisites({ + platform: "win32", + env: { PROGRAMFILES: "C:\\Program Files" }, + exists: (candidate) => candidate === "C:\\Program Files\\Git\\bin\\bash.exe", + }); + expect(result).toEqual({ ok: true }); + }); + + it("fails with an actionable message when Git Bash is missing", () => { + const result = checkKimiWindowsPrerequisites({ + platform: "win32", + env: { PROGRAMFILES: "C:\\Program Files", PATH: "C:\\Windows\\System32" }, + exists: () => false, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failure"); + expect(result.message).toMatch(/Git for Windows/i); + expect(result.message).toMatch(/git-scm.com/i); + }); +}); diff --git a/apps/desktop/src/main/services/ai/acpExecutables.ts b/apps/desktop/src/main/services/ai/acpExecutables.ts new file mode 100644 index 0000000000..0f4852e064 --- /dev/null +++ b/apps/desktop/src/main/services/ai/acpExecutables.ts @@ -0,0 +1,162 @@ +/** + * Binary resolution for the four ACP provider CLIs. + * + * Same shape as `droidExecutable.ts`: an explicit environment override wins, + * then a path the auth detector already proved exists, then PATH and the known + * install directories, then the bare command as a last resort. + * + * Two provider-specific notes: + * + * - `qwen` and `copilot` are npm bins, so on Windows they resolve to a `.cmd` + * shim. `resolveCliSpawnInvocation` in the ACP connection rewrites that into + * the form Node will spawn, so a shim is a usable answer here. + * - `kimi` is a native binary and `grok` is a Rust binary, so + * `preferNativeExecutablePath` picks the real executable when a shim sits + * beside it. + */ + +import fs from "node:fs"; +import type { DetectedAuth } from "./authDetector"; +import { resolveExecutableCandidatesFromKnownLocations } from "./cliExecutableResolver"; +import { preferNativeExecutablePath } from "../shared/processExecution"; +import type { AcpChatProvider } from "../../../shared/types/chat"; + +export type AcpExecutableResolution = { + path: string; + source: "env" | "auth" | "path" | "common-dir" | "fallback-command"; +}; + +/** Environment overrides ADE honors, in order, per provider. */ +const ACP_EXECUTABLE_ENV_KEYS: Record = { + qwen: ["QWEN_EXECUTABLE", "QWEN_CODE_EXECUTABLE"], + kimi: ["KIMI_EXECUTABLE", "KIMI_CODE_EXECUTABLE"], + grok: ["GROK_EXECUTABLE", "XAI_GROK_EXECUTABLE"], + copilot: ["COPILOT_EXECUTABLE", "GITHUB_COPILOT_EXECUTABLE"], +}; + +/** The command name each provider installs. */ +const ACP_EXECUTABLE_COMMANDS: Record = { + qwen: "qwen", + kimi: "kimi", + grok: "grok", + copilot: "copilot", +}; + +function findAcpAuthPath(provider: AcpChatProvider, auth?: DetectedAuth[]): string | null { + for (const entry of auth ?? []) { + if (entry.type !== "cli-subscription" || entry.cli !== provider) continue; + const candidate = entry.path.trim(); + // The detector's last resort is the bare command name, which tells us + // nothing the fallback below would not already say. + if (candidate && candidate !== ACP_EXECUTABLE_COMMANDS[provider]) return candidate; + } + return null; +} + +/** Resolve one ACP provider CLI. Never throws; always returns something spawnable. */ +export function resolveAcpExecutable( + provider: AcpChatProvider, + args?: { auth?: DetectedAuth[]; env?: NodeJS.ProcessEnv }, +): AcpExecutableResolution { + const env = args?.env ?? process.env; + const command = ACP_EXECUTABLE_COMMANDS[provider]; + + for (const key of ACP_EXECUTABLE_ENV_KEYS[provider]) { + const configured = env[key]?.trim(); + if (configured?.length) return { path: configured, source: "env" }; + } + + const authPath = findAcpAuthPath(provider, args?.auth); + if (authPath) return { path: authPath, source: "auth" }; + + const candidates = resolveExecutableCandidatesFromKnownLocations(command, env); + const preferred = preferNativeExecutablePath(candidates.map((candidate) => candidate.path)); + const resolved = candidates.find((candidate) => candidate.path === preferred); + if (resolved) { + return { path: resolved.path, source: resolved.source === "path" ? "path" : "common-dir" }; + } + + return { path: command, source: "fallback-command" }; +} + +/** Resolves the Qwen Code CLI binary (`qwen`). */ +export function resolveQwenExecutable(args?: { + auth?: DetectedAuth[]; + env?: NodeJS.ProcessEnv; +}): AcpExecutableResolution { + return resolveAcpExecutable("qwen", args); +} + +/** Resolves the Kimi Code CLI binary (`kimi`). */ +export function resolveKimiExecutable(args?: { + auth?: DetectedAuth[]; + env?: NodeJS.ProcessEnv; +}): AcpExecutableResolution { + return resolveAcpExecutable("kimi", args); +} + +/** Resolves the xAI Grok CLI binary (`grok`). */ +export function resolveGrokExecutable(args?: { + auth?: DetectedAuth[]; + env?: NodeJS.ProcessEnv; +}): AcpExecutableResolution { + return resolveAcpExecutable("grok", args); +} + +/** Resolves the GitHub Copilot CLI binary (`copilot`). */ +export function resolveCopilotExecutable(args?: { + auth?: DetectedAuth[]; + env?: NodeJS.ProcessEnv; +}): AcpExecutableResolution { + return resolveAcpExecutable("copilot", args); +} + +/** + * Kimi's native binary uses Git Bash as its shell on Windows, so it cannot run + * without Git for Windows installed. The check is win32-only; every other + * platform returns `ok`. + * + * Returning a reason rather than throwing keeps the message in one place: the + * chat runtime turns it into one visible error, and the tracked-CLI launcher + * turns it into a launch failure, from the same text. + */ +export function checkKimiWindowsPrerequisites(args?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + /** Test seam. Reports whether a candidate path exists on disk. */ + exists?: (candidate: string) => boolean; +}): { ok: true } | { ok: false; message: string } { + const platform = args?.platform ?? process.platform; + if (platform !== "win32") return { ok: true }; + const env = args?.env ?? process.env; + const exists = args?.exists ?? ((candidate: string) => { + try { + return fs.existsSync(candidate); + } catch { + return false; + } + }); + + const roots = [ + env.PROGRAMFILES, + env["PROGRAMFILES(X86)"], + env.LOCALAPPDATA ? `${env.LOCALAPPDATA}\\Programs` : undefined, + ].filter((root): root is string => Boolean(root?.trim().length)); + + const candidates = roots.map((root) => `${root}\\Git\\bin\\bash.exe`); + if (candidates.some(exists)) return { ok: true }; + + // A Git Bash already on PATH is just as good, and it is how a scoop or + // chocolatey install usually presents itself. + const pathCandidates = resolveExecutableCandidatesFromKnownLocations("bash", env); + if (pathCandidates.some((candidate) => candidate.path.toLowerCase().includes("git"))) { + return { ok: true }; + } + + return { + ok: false, + message: + "Kimi needs Git for Windows: Git Bash is the shell its binary runs commands through. " + + "Install Git for Windows from https://git-scm.com/download/win, then try again.", + }; +} diff --git a/apps/desktop/src/main/services/ai/acpProviderDiagnostics.test.ts b/apps/desktop/src/main/services/ai/acpProviderDiagnostics.test.ts new file mode 100644 index 0000000000..2f258c06ba --- /dev/null +++ b/apps/desktop/src/main/services/ai/acpProviderDiagnostics.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { + acpProviderSupportsDoctor, + collectAcpProviderDiagnostics, + formatAcpProviderDiagnosticsReport, +} from "./acpProviderDiagnostics"; + +/** A `spawnAsync` stand-in. Same contract: resolves, never rejects. */ +function fakeRun(byArg: Record) { + return vi.fn(async (_command: string, args: string[]) => { + const key = args.join(" "); + const result = byArg[key] ?? { status: null }; + return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; + }) as never; +} + +const env = { PATH: "", GROK_EXECUTABLE: "/opt/bin/grok", KIMI_EXECUTABLE: "/opt/bin/kimi", QWEN_EXECUTABLE: "/opt/bin/qwen" }; + +describe("acpProviderDiagnostics", () => { + it("declares doctor support rather than guessing it", () => { + expect(acpProviderSupportsDoctor("grok")).toBe(true); + expect(acpProviderSupportsDoctor("kimi")).toBe(true); + // Qwen and Copilot ship no `doctor`; passing the word would be read as a + // prompt by the agent. + expect(acpProviderSupportsDoctor("qwen")).toBe(false); + expect(acpProviderSupportsDoctor("copilot")).toBe(false); + }); + + it("reports version and config home without running doctor by default", async () => { + const run = fakeRun({ "--version": { status: 0, stdout: "1.0.14\n" } }); + const result = await collectAcpProviderDiagnostics({ provider: "grok", cwd: "/repo", env, run }); + + expect(result.version).toBe("1.0.14"); + expect(result.versionError).toBeNull(); + expect(result.binaryPath).toBe("/opt/bin/grok"); + expect(result.binarySource).toBe("env"); + expect(result.configHome).toMatch(/\.grok$/); + expect(result.doctor).toBeNull(); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("folds doctor output in when asked", async () => { + const run = fakeRun({ + "--version": { status: 0, stdout: "1.0.14" }, + doctor: { status: 1, stdout: "network: ok\n", stderr: "auth: missing\n" }, + }); + const result = await collectAcpProviderDiagnostics({ + provider: "kimi", + cwd: "/repo", + env, + runDoctor: true, + run, + }); + + expect(result.doctor).toMatchObject({ command: "kimi doctor", exitCode: 1 }); + expect(result.doctor?.output).toContain("auth: missing"); + }); + + it("ignores a doctor request for a provider that has no doctor", async () => { + const run = fakeRun({ "--version": { status: 0, stdout: "0.9.0" } }); + const result = await collectAcpProviderDiagnostics({ + provider: "qwen", + cwd: "/repo", + env, + runDoctor: true, + run, + }); + + expect(result.doctor).toBeNull(); + expect(run).toHaveBeenCalledTimes(1); + }); + + // A `--version` that times out resolves with `status: null`. Reporting that + // as a version would put "null" on the settings page. + it("names why there is no version instead of inventing one", async () => { + const run = fakeRun({ "--version": { status: null, stderr: "killed after timeout" } }); + const result = await collectAcpProviderDiagnostics({ provider: "grok", cwd: "/repo", env, run }); + + expect(result.version).toBeNull(); + expect(result.versionError).toBe("killed after timeout"); + }); + + it("names every absent fact in the copyable report", () => { + const report = formatAcpProviderDiagnosticsReport({ + provider: "grok", + binaryPath: null, + binarySource: "fallback-command", + configHome: null, + version: null, + versionError: "not found", + lastProbe: null, + doctor: null, + checkedAt: "2026-08-31T00:00:00.000Z", + }); + + expect(report).toContain("binary: not found"); + expect(report).toContain("config home: n/a"); + expect(report).toContain("last auth probe: not run"); + expect(report).toContain("status: unknown"); + }); +}); diff --git a/apps/desktop/src/main/services/ai/acpProviderDiagnostics.ts b/apps/desktop/src/main/services/ai/acpProviderDiagnostics.ts new file mode 100644 index 0000000000..3fdcc91554 --- /dev/null +++ b/apps/desktop/src/main/services/ai/acpProviderDiagnostics.ts @@ -0,0 +1,168 @@ +/** + * What ADE can say about one ACP provider CLI without opening a chat. + * + * Settings needs four facts that no status payload carries — where the binary + * resolved from, which directory the CLI keeps its config in, what version it + * is, and what the last auth probe concluded — plus, for the two vendors that + * ship one, the output of their own `doctor` command. + * + * None of this runs on a status refresh: `--version` and `doctor` are process + * spawns, and spawning four CLIs to draw a grid of tiles is the mistake + * `acpAuthProbe`'s header warns about. This is called when a provider's detail + * page opens, and again when someone presses "Run doctor". + */ + +import type { AcpChatProvider } from "../../../shared/types/chat"; +import type { AcpProviderDiagnostics } from "../../../shared/types/config"; +import { spawnAsync } from "../shared/utils"; +import { grokConfigHome } from "../shared/providerConfigHomes"; +import { acpProbeConfigHome, getCachedAcpAuthProbe } from "./acpAuthProbe"; +import { resolveAcpExecutable } from "./acpExecutables"; + +const VERSION_TIMEOUT_MS = 6_000; +const DOCTOR_TIMEOUT_MS = 25_000; +const DOCTOR_MAX_OUTPUT_BYTES = 20_000; + +/** + * Which providers ship a `doctor` subcommand. + * + * Qwen and Copilot have none. Offering the button anyway would run their + * argument as a prompt, which is the failure mode the slash-command allowlist + * exists to prevent — so the capability is declared, not guessed. + */ +const DOCTOR_COMMANDS: Partial> = { + grok: ["doctor"], + kimi: ["doctor"], +}; + +export function acpProviderSupportsDoctor(provider: AcpChatProvider): boolean { + return DOCTOR_COMMANDS[provider] != null; +} + +/** Config directory each CLI reads. Grok's is fixed at `~/.grok`. */ +function configHomeFor(provider: AcpChatProvider, env: NodeJS.ProcessEnv): string { + return provider === "grok" ? grokConfigHome({ env }) : acpProbeConfigHome(provider, env) ?? ""; +} + +/** + * First line of `--version` output. + * + * CLIs print anything from `1.0.14` to a banner with an update notice, so the + * first non-empty line is taken and the rest dropped rather than parsed. + */ +function firstVersionLine(stdout: string, stderr: string): string | null { + const text = `${stdout}\n${stderr}`; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length) return trimmed.slice(0, 120); + } + return null; +} + +export type CollectAcpProviderDiagnosticsArgs = { + provider: AcpChatProvider; + /** Working directory the probe cache is keyed by. A lane worktree or the project root. */ + cwd: string; + /** Also run the vendor's `doctor`. Off by default — it is the slow half. */ + runDoctor?: boolean; + env?: NodeJS.ProcessEnv; + /** Test seam. Same contract as `spawnAsync`: resolves, never rejects. */ + run?: typeof spawnAsync; +}; + +export async function collectAcpProviderDiagnostics( + args: CollectAcpProviderDiagnosticsArgs, +): Promise { + const env = args.env ?? process.env; + const run = args.run ?? spawnAsync; + const executable = resolveAcpExecutable(args.provider, { env }); + // "fallback-command" means nothing was found and the bare name is a guess, so + // reporting it as a path would be a claim ADE cannot make. + const binaryPath = executable.source === "fallback-command" ? null : executable.path; + const probe = getCachedAcpAuthProbe(args.provider, args.cwd); + + const base: AcpProviderDiagnostics = { + provider: args.provider, + binaryPath, + binarySource: executable.source, + configHome: configHomeFor(args.provider, env) || null, + version: null, + versionError: null, + lastProbe: probe + ? { state: probe.state, message: probe.message } + : null, + doctor: null, + checkedAt: new Date().toISOString(), + }; + + if (!binaryPath) { + return { ...base, versionError: `\`${args.provider}\` was not found on this machine.` }; + } + + const version = await run(executable.path, ["--version"], { + timeout: VERSION_TIMEOUT_MS, + cwd: args.cwd, + }); + const versionLine = version.status === 0 ? firstVersionLine(version.stdout, version.stderr) : null; + const result: AcpProviderDiagnostics = { + ...base, + version: versionLine, + versionError: versionLine + ? null + : firstVersionLine(version.stderr, version.stdout) ?? "The CLI did not report a version.", + }; + + const doctorArgs = DOCTOR_COMMANDS[args.provider]; + if (!args.runDoctor || !doctorArgs) return result; + + const doctor = await run(executable.path, [...doctorArgs], { + timeout: DOCTOR_TIMEOUT_MS, + maxOutputBytes: DOCTOR_MAX_OUTPUT_BYTES, + cwd: args.cwd, + }); + const output = `${doctor.stdout}${doctor.stderr}`.trim(); + return { + ...result, + doctor: { + command: `${args.provider} ${doctorArgs.join(" ")}`, + exitCode: doctor.status, + output: output.length + ? output + : doctor.status === null + ? "No output — the command timed out or could not start." + : "No output.", + }, + }; +} + +/** + * The copyable diagnostic report for one provider. + * + * Plain text on purpose: it is pasted into a GitHub issue, and every line has + * to survive that trip. Absent facts say "unknown" rather than disappearing — + * a missing line reads as "not checked", which is a different claim. + */ +export function formatAcpProviderDiagnosticsReport( + diagnostics: AcpProviderDiagnostics, + extra?: { status?: string | null }, +): string { + const lines = [ + `provider: ${diagnostics.provider}`, + `status: ${extra?.status?.trim() || "unknown"}`, + `version: ${diagnostics.version ?? `unknown (${diagnostics.versionError ?? "no detail"})`}`, + `binary: ${diagnostics.binaryPath ?? "not found"} (${diagnostics.binarySource})`, + `config home: ${diagnostics.configHome ?? "n/a"}`, + `last auth probe: ${diagnostics.lastProbe + ? `${diagnostics.lastProbe.state}${diagnostics.lastProbe.message ? ` — ${diagnostics.lastProbe.message}` : ""}` + : "not run"}`, + `checked at: ${diagnostics.checkedAt}`, + ]; + if (diagnostics.doctor) { + lines.push( + "", + `$ ${diagnostics.doctor.command} (exit ${diagnostics.doctor.exitCode ?? "none"})`, + diagnostics.doctor.output, + ); + } + return lines.join("\n"); +} diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index 24b4fe579d..fee6771a31 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -24,6 +24,7 @@ const mockState = vi.hoisted(() => ({ getModelsDevLastFetchedAt: vi.fn((..._args: unknown[]) => null as number | null), clearOpenCodeBinaryCache: vi.fn(), resolveOpenCodeBinary: vi.fn(), + probeAllAcpProviderAuth: vi.fn(), })); vi.mock("./authDetector", () => ({ @@ -37,6 +38,18 @@ vi.mock("./providerConnectionStatus", () => ({ buildProviderConnections: (...args: unknown[]) => mockState.buildProviderConnections(...args), })); +vi.mock("./acpAuthProbe", () => ({ + probeAllAcpProviderAuth: (...args: unknown[]) => mockState.probeAllAcpProviderAuth(...args), +})); + +vi.mock("./qwenUserSettings", () => ({ + loadQwenUserSettings: vi.fn(async () => ({ + authenticated: false, + models: [], + defaultModelId: null, + })), +})); + vi.mock("./localModelDiscovery", () => ({ inspectLocalProvider: (...args: unknown[]) => mockState.inspectLocalProvider(...args), })); @@ -618,6 +631,60 @@ describe("aiIntegrationService", () => { expect(mockState.probeClaudeRuntimeHealth).not.toHaveBeenCalled(); }); + it("waits for an ACP auth verdict before publishing provider status", async () => { + const { service } = makeService({ + availability: { claude: false, codex: false, cursor: false, droid: false }, + }); + mockState.getCachedCliAuthStatuses.mockReturnValue([ + { + cli: "copilot", + installed: true, + path: "/usr/local/bin/copilot", + authenticated: false, + verified: false, + }, + ]); + mockState.detectAllAuth.mockResolvedValue([]); + mockState.probeAllAcpProviderAuth.mockResolvedValue({ + copilot: { state: "ready", message: null }, + }); + const base = makeProviderConnections({ claude: false, codex: false, cursor: false, droid: false }); + mockState.buildProviderConnections.mockResolvedValue({ + ...base, + copilot: { + provider: "copilot", + authAvailable: true, + runtimeDetected: true, + runtimeAvailable: true, + usageAvailable: false, + path: "/usr/local/bin/copilot", + blocker: null, + lastCheckedAt: "2025-01-01T00:00:00.000Z", + sources: [], + }, + }); + + const status = await service.getStatus({ force: true }); + + expect(mockState.probeAllAcpProviderAuth).toHaveBeenCalledWith(expect.objectContaining({ + providers: ["copilot"], + cwd: "/tmp/project", + })); + expect(status.availableProviders.copilot).toBe(true); + expect(status.models.copilot?.map((model) => model.id)).toEqual([ + "github-copilot/claude-sonnet-4.6", + "github-copilot/claude-opus-4.6", + "github-copilot/gpt-5.4", + "github-copilot/gpt-5.3-codex", + ]); + expect(status.detectedAuth).toContainEqual(expect.objectContaining({ + type: "cli-subscription", + cli: "copilot", + authenticated: true, + verified: true, + })); + }); + it("invalidates provider readiness caches after API key verification", async () => { const { service } = makeService({ providerMode: "guest", diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 9ba6d5ca99..5f3099520c 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -29,13 +29,19 @@ import { getAvailableModels, getLocalProviderDefaultEndpoint, isLocalProviderFamily, + listAcpModelDescriptorsForProvider, listModelDescriptorsForProvider, + mergeDynamicAcpModelDescriptors, + createDynamicAcpModelDescriptor, LOCAL_PROVIDER_LABELS, replaceDynamicOpenCodeModelDescriptors, resolveModelAlias, resolveProviderGroupForModel, type LocalProviderFamily, } from "../../../shared/modelRegistry"; +import { disabledProviderSet } from "../../../shared/providerEnablement"; +import { probeAllAcpProviderAuth } from "./acpAuthProbe"; +import { loadQwenUserSettings } from "./qwenUserSettings"; import { detectAllAuth, getCachedCliAuthStatuses, @@ -80,7 +86,11 @@ import { discoverDroidCliModelDescriptors, markDroidModelCachesStale } from "../ import { resolveDroidExecutable } from "./droidExecutable"; import { buildProviderConnections } from "./providerConnectionStatus"; import { piModelDescriptorsFromInventory, probePiProfileInventory, resolvePiInstallation } from "./piInstallation"; -import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth"; +import { + getProviderRuntimeHealth, + getProviderRuntimeHealthVersion, + resetProviderRuntimeHealth, +} from "./providerRuntimeHealth"; import { resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe"; import { runProviderTask } from "./providerTaskRunner"; import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; @@ -131,16 +141,26 @@ export type AiIntegrationStatus = { codex: boolean; cursor: boolean; droid: boolean; + // Optional, mirroring `AiSettingsStatus`: a host on an older build has no + // arm for these and its payload must still deserialise. + qwen?: boolean; + kimi?: boolean; + grok?: boolean; + copilot?: boolean; }; models: { claude: AgentModelDescriptor[]; codex: AgentModelDescriptor[]; cursor: AgentModelDescriptor[]; droid: AgentModelDescriptor[]; + qwen?: AgentModelDescriptor[]; + kimi?: AgentModelDescriptor[]; + grok?: AgentModelDescriptor[]; + copilot?: AgentModelDescriptor[]; }; detectedAuth?: Array<{ type: "cli-subscription" | "api-key" | "oauth" | "openrouter" | "local"; - cli?: "claude" | "codex" | "cursor" | "droid"; + cli?: "claude" | "codex" | "cursor" | "droid" | "qwen" | "kimi" | "grok" | "copilot"; provider?: string; source?: "config" | "env" | "store" | "file"; endpointSource?: "auto" | "config"; @@ -379,6 +399,7 @@ function resolveBundledClaudeBinary(): Pick normalizedBlocker.includes(needle)); - const ready = binary.present + const ready = options?.disabled !== true + && binary.present && connection.authAvailable && (connection.runtimeAvailable || blockerIsOnlyAboutPath || !connection.blocker); return { @@ -537,6 +559,7 @@ function hasUsableDetectedAuth(auth: DetectedAuth[]): boolean { function redactDetectedAuth( auth: DetectedAuth[], cliStatuses: CliAuthStatus[], + runtimeReadyAcpProviders?: ReadonlySet, ): NonNullable { const redacted = auth.map((entry) => { if (entry.type === "cli-subscription") { @@ -573,6 +596,7 @@ function redactDetectedAuth( for (const cliStatus of cliStatuses) { if (!cliStatus.installed) continue; + const runtimeReady = runtimeReadyAcpProviders?.has(cliStatus.cli) === true; const existingIndex = redacted.findIndex( (entry) => entry.type === "cli-subscription" && entry.cli === cliStatus.cli, ); @@ -580,8 +604,8 @@ function redactDetectedAuth( type: "cli-subscription" as const, cli: cliStatus.cli, path: cliStatus.path ?? cliStatus.cli, - authenticated: cliStatus.authenticated, - verified: cliStatus.verified, + authenticated: runtimeReady || cliStatus.authenticated, + verified: runtimeReady || cliStatus.verified, }; if (existingIndex >= 0) { redacted[existingIndex] = normalizedEntry; @@ -918,6 +942,20 @@ function agentModelsFromAvailable( type ModelDescriptorForStatus = ReturnType[number]; +/** + * Model family behind each ACP provider's settings page. + * + * Curated rows come from the registry and live-discovered rows are folded into + * it by the ACP host, so one family lookup answers both — the settings page + * does not need to know which of the two a row came from. + */ +export const ACP_STATUS_FAMILIES = { + qwen: "qwen", + kimi: "moonshot", + grok: "xai", + copilot: "github-copilot", +} as const; + function buildStatusModelLists( available: ModelDescriptorForStatus[], availability: AiIntegrationStatus["availableProviders"], @@ -927,6 +965,14 @@ function buildStatusModelLists( codex: availability.codex ? agentModelsFromAvailable(available, "openai") : [], cursor: availability.cursor ? agentModelsFromAvailable(available, "cursor") : [], droid: availability.droid ? agentModelsFromAvailable(available, "factory") : [], + // The ACP providers list their models whenever the CLI is present, not only + // once signed in: the curated list is what the settings page is for, and an + // empty page would say "this provider has no models" when what is true is + // "you are not signed in yet" — which the status word already says. + qwen: availability.qwen ? agentModelsFromAvailable(available, ACP_STATUS_FAMILIES.qwen) : [], + kimi: availability.kimi ? agentModelsFromAvailable(available, ACP_STATUS_FAMILIES.kimi) : [], + grok: availability.grok ? agentModelsFromAvailable(available, ACP_STATUS_FAMILIES.grok) : [], + copilot: availability.copilot ? agentModelsFromAvailable(available, ACP_STATUS_FAMILIES.copilot) : [], }; } @@ -1025,6 +1071,42 @@ export function createAiIntegrationService(args: { // which populates dynamic OpenCode descriptors (including local providers). let available = getAvailableModels(auth); + // ACP model ids are provider-owned. In particular, Qwen can be pointed at + // an arbitrary OpenAI-compatible endpoint, so its own settings are the + // source of truth when they name models; ADE's curated Alibaba rows are + // only a fallback for an otherwise unconfigured Qwen CLI. + const qwenSettings = await loadQwenUserSettings(); + if (qwenSettings.models.length) { + mergeDynamicAcpModelDescriptors( + "qwen", + qwenSettings.models.map((model) => createDynamicAcpModelDescriptor("qwen", model.id, { + displayName: model.displayName, + })), + ); + } + const acpModelFamilies = [ + ["qwen", ACP_STATUS_FAMILIES.qwen], + ["kimi", ACP_STATUS_FAMILIES.kimi], + ["grok", ACP_STATUS_FAMILIES.grok], + ["copilot", ACP_STATUS_FAMILIES.copilot], + ] as const; + for (const [provider, family] of acpModelFamilies) { + const health = getProviderRuntimeHealth(provider); + const hasAuth = health + ? health.state === "ready" + : auth.some((entry) => entry.type === "cli-subscription" && entry.cli === provider && entry.authenticated !== false); + // An explicit failed health verdict must remove stale curated rows too; + // otherwise a signed-out provider still looks selectable in the picker. + available = available.filter((descriptor) => + !(descriptor.isCliWrapped && descriptor.family === family) + ); + if (!hasAuth) continue; + available.push(...listAcpModelDescriptorsForProvider(provider, { + ...(provider === "qwen" && qwenSettings.models.length + ? { configuredModelIds: qwenSettings.models.map((model) => model.id) } + : {}), + })); + } const discoveryMode = options?.discoverCliModels === true ? "probe" : "cached-or-fallback"; // "cached-or-fallback" serves last-known-good rows and warms the cache in // the background when cold, so a verified key surfaces models on passive @@ -1836,12 +1918,48 @@ export function createAiIntegrationService(args: { force: options?.force, shallowCliAuth: !shouldProbeCliModels, })); - const available = await timePhase("resolve_available_models", () => - getResolvedAvailableModels(auth, { discoverCliModels: shouldProbeCliModels }) - ); // detectAuth -> detectAllAuth already called detectCliAuthStatuses() and // populated the cache, so this reads instantly from cache: const cliStatuses = timeSyncPhase("read_cli_auth_cache", () => getCachedCliAuthStatuses()); + const installedAcp = ( ["qwen", "kimi", "grok", "copilot"] as const) + .filter((provider) => cliStatuses.some((status) => status.cli === provider && status.installed)); + // The disk heuristic proves only that a provider left credentials on + // disk. A forced Settings refresh must wait for the ACP handshake so + // the first payload says what the user can actually run. The old + // fire-and-forget call returned stale cards (notably Copilot, whose + // keychain login is not represented in config.json) and never + // refreshed the renderer with its verdict. + const acpProbeResults = options?.force === true && installedAcp.length + ? await timePhase("probe_acp_auth", () => probeAllAcpProviderAuth({ + providers: installedAcp, + cwd: projectRoot, + logger, + })) + : {}; + const runtimeReadyAcpProviders = new Set(); + for (const provider of ["qwen", "kimi", "grok", "copilot"] as const) { + const probe = acpProbeResults[provider]; + const health = getProviderRuntimeHealth(provider); + if (probe?.state === "ready" || health?.state === "ready") { + runtimeReadyAcpProviders.add(provider); + } + } + runtimeHealthVersion = getProviderRuntimeHealthVersion(); + const authForModels: DetectedAuth[] = [...auth]; + for (const provider of runtimeReadyAcpProviders) { + if (authForModels.some((entry) => entry.type === "cli-subscription" && entry.cli === provider)) continue; + const cli = cliStatuses.find((status) => status.cli === provider); + authForModels.push({ + type: "cli-subscription", + cli: provider as "qwen" | "kimi" | "grok" | "copilot", + path: cli?.path ?? provider, + authenticated: true, + verified: true, + }); + } + const available = await timePhase("resolve_available_models", () => + getResolvedAvailableModels(authForModels, { discoverCliModels: shouldProbeCliModels }) + ); const piInstallation = timeSyncPhase("resolve_pi_installation", () => resolvePiInstallation()); const piProfileInventory = await timePhase("pi_inventory", () => probePiProfileInventory(piInstallation)); replaceDynamicPiModelDescriptors(piModelDescriptorsFromInventory(piProfileInventory)); @@ -1862,18 +1980,38 @@ export function createAiIntegrationService(args: { auth, providerConnections, })); + // A provider the user switched off in Settings offers nothing + // anywhere: no availability, no model rows, no ids in the merged + // list. Its settings tile still renders (as "Disabled") from the + // connection status, so the switch stays findable. + const disabledProviders = disabledProviderSet(projectConfigService.get().effective.ai); + const enabled = (provider: string): boolean => !disabledProviders.has(provider); const availability: AiIntegrationStatus["availableProviders"] = { - claude: buildClaudeAvailabilityFromConnection(providerConnections.claude), - codex: providerConnections.codex.runtimeAvailable, - cursor: providerConnections.cursor.runtimeAvailable, - droid: providerConnections.droid.runtimeAvailable, + claude: buildClaudeAvailabilityFromConnection( + providerConnections.claude, + { disabled: !enabled("claude") }, + ), + codex: enabled("codex") && providerConnections.codex.runtimeAvailable, + cursor: enabled("cursor") && providerConnections.cursor.runtimeAvailable, + droid: enabled("droid") && providerConnections.droid.runtimeAvailable, + qwen: enabled("qwen") && Boolean(providerConnections.qwen?.runtimeAvailable), + kimi: enabled("kimi") && Boolean(providerConnections.kimi?.runtimeAvailable), + grok: enabled("grok") && Boolean(providerConnections.grok?.runtimeAvailable), + copilot: enabled("copilot") && Boolean(providerConnections.copilot?.runtimeAvailable), }; const runtimeFilteredAvailable = timeSyncPhase("filter_available_models", () => available.filter((descriptor) => { + // API/local rows are not owned by any one provider tile (they reach + // ADE through OpenCode, Pi, or a local runtime), so the tile toggle + // does not speak for them. The catalog gate handles those groups. if (!descriptor.isCliWrapped) return true; if (descriptor.family === "anthropic") return availability.claude.auth.ready; - if (descriptor.family === "openai") return providerConnections.codex.runtimeAvailable; - if (descriptor.family === "cursor") return providerConnections.cursor.runtimeAvailable; - if (descriptor.family === "factory") return providerConnections.droid.runtimeAvailable; + if (descriptor.family === "openai") return enabled("codex") && providerConnections.codex.runtimeAvailable; + if (descriptor.family === "cursor") return enabled("cursor") && providerConnections.cursor.runtimeAvailable; + if (descriptor.family === "factory") return enabled("droid") && providerConnections.droid.runtimeAvailable; + if (descriptor.family === ACP_STATUS_FAMILIES.qwen) return availability.qwen === true; + if (descriptor.family === ACP_STATUS_FAMILIES.kimi) return availability.kimi === true; + if (descriptor.family === ACP_STATUS_FAMILIES.grok) return availability.grok === true; + if (descriptor.family === ACP_STATUS_FAMILIES.copilot) return availability.copilot === true; return true; })); @@ -1958,7 +2096,7 @@ export function createAiIntegrationService(args: { availableProviders: availability, models, detectedAuth: timeSyncPhase("redact_auth", () => [ - ...redactDetectedAuth(auth, cliStatuses), + ...redactDetectedAuth(auth, cliStatuses, runtimeReadyAcpProviders), ...redactPiDetectedAuth(piProfileInventory), ]), providerConnections, diff --git a/apps/desktop/src/main/services/ai/authDetector.test.ts b/apps/desktop/src/main/services/ai/authDetector.test.ts index 749a53b64b..765788db8a 100644 --- a/apps/desktop/src/main/services/ai/authDetector.test.ts +++ b/apps/desktop/src/main/services/ai/authDetector.test.ts @@ -56,6 +56,18 @@ function commandBasename(command: string): string { return command.replace(/\\/g, "/").split("/").pop() ?? command; } +function hideRealExecutable(command: string) { + const realStatSync = fs.statSync.bind(fs); + return vi.spyOn(fs, "statSync").mockImplementation(((candidatePath: fs.PathLike, options?: fs.StatOptions) => { + if (commandBasename(String(candidatePath)) === command) { + const error = new Error(`ENOENT: no such file or directory, stat '${String(candidatePath)}'`) as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return realStatSync(candidatePath, options as fs.StatOptions | undefined); + }) as typeof fs.statSync); +} + function withExecutableMode(stat: fs.Stats): fs.Stats { return new Proxy(stat, { get(target, property, receiver) { @@ -163,6 +175,7 @@ describe("authDetector", () => { }); it("reports installed-but-unauthenticated CLI providers", async () => { + hideRealExecutable("claude"); spawnMock.mockImplementation((command: string, args: string[] = []) => { // commandExists: direct spawn strategy if (args[0] === "--version") { @@ -193,6 +206,7 @@ describe("authDetector", () => { }); it("can skip expensive CLI auth probes for passive status checks", async () => { + hideRealExecutable("claude"); spawnMock.mockImplementation((command: string, args: string[] = []) => { if (args[0] === "--version") { if (command === "claude") return fakeChild({ status: 0, stdout: "1.0.0\n" }); @@ -592,6 +606,7 @@ describe("authDetector", () => { }); it("repairs PATH from the interactive shell during a forced refresh", async () => { + hideRealExecutable("codex"); process.env.PATH = "/usr/bin:/bin:/usr/sbin:/sbin"; process.env.SHELL = "/bin/zsh"; diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 9237e45e92..6b4f56468e 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -16,6 +16,7 @@ import { CURSOR_CLI_EXECUTABLES } from "../../../shared/providerCliExecutables"; import type { AiLocalProviderConfigs } from "../../../shared/types"; import { inspectLocalProvider, clearLocalProviderInspectionCache } from "./localModelDiscovery"; import { resolveDroidExecutable } from "./droidExecutable"; +import { loadQwenUserSettings } from "./qwenUserSettings"; import { reportProviderRuntimeAuthFailure, reportProviderRuntimeFailure, @@ -23,7 +24,26 @@ import { } from "./providerRuntimeHealth"; import { loadCursorSdk } from "./cursorSdkLoader"; -type CliName = "claude" | "codex" | "cursor" | "droid"; +type CliName = + | "claude" + | "codex" + | "cursor" + | "droid" + | "qwen" + | "kimi" + | "grok" + | "copilot"; + +/** + * CLIs ADE reaches over the Agent Client Protocol. Their auth state is read + * from disk, not from a spawn: see `inspectAcpCliCredentials`. + */ +const ACP_CLI_NAMES = ["qwen", "kimi", "grok", "copilot"] as const; +type AcpCliName = (typeof ACP_CLI_NAMES)[number]; + +function isAcpCliName(cli: CliName): cli is AcpCliName { + return (ACP_CLI_NAMES as readonly string[]).includes(cli); +} type ApiKeySource = "config" | "env" | "store"; @@ -49,7 +69,7 @@ export type CliAuthStatus = { export type DetectedAuth = | { type: "cli-subscription"; - cli: "claude" | "codex" | "cursor" | "droid"; + cli: "claude" | "codex" | "cursor" | "droid" | AcpCliName; path: string; authenticated: boolean; verified: boolean; @@ -88,8 +108,89 @@ const CLI_AUTH_PROBES: Record = { // real subcommand — `version`, `whoami`, `account status` — is taken as a // *prompt* and boots the full interactive TUI, burning the spawn timeout. droid: [["--version"], ["-v"]], + // The ACP CLIs have no cheap, non-interactive auth subcommand: `qwen`, + // `kimi`, and `grok` all boot their TUI for anything that is not a + // recognised flag, and `copilot`'s only status surface is inside the + // session. Their credentials are read off disk instead — see + // `inspectAcpCliCredentials` — and the protocol-level `authenticate` + // handshake belongs to the ACP host, not to this presence probe. + qwen: [], + kimi: [], + grok: [], + copilot: [], }; +/** + * Where each ACP CLI keeps the artifact that proves a completed login. + * + * These are heuristics, and they are reported as such: `verified` stays false, + * because ADE has not asked the provider whether the credential still works. + * Only the ACP host's `authenticate` round-trip can say that. + */ +async function inspectAcpCliCredentials( + cli: AcpCliName, +): Promise<{ authenticated: boolean; verified: false }> { + const home = homedir(); + const env = process.env; + const dir = (override: string | undefined, fallback: string): string => { + const configured = override?.trim(); + return configured?.length ? path.resolve(configured) : path.join(home, fallback); + }; + + if (cli === "grok") { + // `~/.grok` only — Grok honours no config-home override, so ADE must not + // invent one. A stored session token outranks XAI_API_KEY inside Grok + // itself, but either one means the CLI can start. + if (env.XAI_API_KEY?.trim()) return { authenticated: true, verified: false }; + return { authenticated: await fileExists(path.join(home, ".grok", "auth.json")), verified: false }; + } + + if (cli === "qwen") { + if (env.OPENAI_API_KEY?.trim() || env.QWEN_API_KEY?.trim() || env.DASHSCOPE_API_KEY?.trim()) { + return { authenticated: true, verified: false }; + } + const root = dir(env.QWEN_HOME, ".qwen"); + const [oauth, dotenvFile, settings] = await Promise.all([ + fileExists(path.join(root, "oauth_creds.json")), + fileExists(path.join(root, ".env")), + loadQwenUserSettings({ env }), + ]); + return { authenticated: oauth || dotenvFile || settings.authenticated, verified: false }; + } + + if (cli === "kimi") { + const root = dir(env.KIMI_CODE_HOME, ".kimi-code"); + return { authenticated: await fileExists(path.join(root, "config.toml")), verified: false }; + } + + // Copilot's durable login is normally keychain/session-state backed, not a + // reliable JSON field in config.json. Environment tokens are still a useful + // presence hint; the ACP handshake remains the authority. Keep the legacy + // JSON read as a best-effort fallback, and never write or rewrite this file + // (it is JSONC on current Copilot versions). + if (env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim()) { + return { authenticated: true, verified: false }; + } + const root = dir(env.COPILOT_HOME, ".copilot"); + try { + const raw = await readFile(path.join(root, "config.json"), "utf8"); + const parsed = JSON.parse(raw) as { logged_in_users?: unknown }; + const users = Array.isArray(parsed.logged_in_users) ? parsed.logged_in_users : []; + return { authenticated: users.length > 0, verified: false }; + } catch { + return { authenticated: false, verified: false }; + } +} + +async function fileExists(target: string): Promise { + try { + await readFile(target); + return true; + } catch { + return false; + } +} + function cliSpawnCommands(cli: CliName): readonly string[] { if (cli === "cursor") return CURSOR_CLI_EXECUTABLES.launchCandidates; return [cli]; @@ -1122,7 +1223,7 @@ export async function detectCliAuthStatuses(options?: { force?: boolean; skipAut await refreshProcessPathFromShell(); } - const cliChecks: CliName[] = ["claude", "codex", "cursor", "droid"]; + const cliChecks: CliName[] = ["claude", "codex", "cursor", "droid", ...ACP_CLI_NAMES]; // Probe all CLIs in parallel const statuses = await Promise.all( @@ -1148,6 +1249,11 @@ export async function detectCliAuthStatuses(options?: { force?: boolean; skipAut verified: false, }; } + if (isAcpCliName(cli)) { + // Disk-only, so there is nothing for `skipAuthProbe` to skip. + const auth = await inspectAcpCliCredentials(cli); + return { cli, installed, path, authenticated: auth.authenticated, verified: auth.verified }; + } if (skipAuthProbe && cli !== "droid") { return { cli, @@ -1210,7 +1316,6 @@ export async function detectAllAuth( skipAuthProbe: options?.skipCliAuthProbe, }); for (const cli of cliStatuses) { - if (cli.cli !== "claude" && cli.cli !== "codex" && cli.cli !== "cursor" && cli.cli !== "droid") continue; if (!cli.installed) continue; if (!cli.authenticated && cli.verified) continue; results.push({ diff --git a/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts b/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts index f31a7fe806..7df98aa898 100644 --- a/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts +++ b/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,6 +9,13 @@ import { loadToolsManifest, } from "../../../../../ade-cli/src/services/tools"; +const emptyToolsRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-claude-empty-tools-")); +afterAll(() => fs.rmSync(emptyToolsRoot, { recursive: true, force: true })); + +function envWithoutCachedTool(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ADE_TOOLS_ROOT: emptyToolsRoot, ...overrides }; +} + /** * Materialize a pinned tool into a throwaway cache root exactly as an install * leaves it: the real package/version directory, the entry file, and the @@ -58,9 +65,9 @@ describe("resolveClaudeCodeExecutable", () => { verified: true, }, ], - env: { + env: envWithoutCachedTool({ PATH: "/usr/bin:/bin", - }, + }), }), ).toEqual({ path: "/opt/homebrew/bin/claude", @@ -93,9 +100,9 @@ describe("resolveClaudeCodeExecutable", () => { verified: true, }, ], - env: { + env: envWithoutCachedTool({ PATH: "/usr/bin:/bin", - }, + }), resourcesPath, platform: "darwin", arch: "arm64", diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts index 33d8cc732d..67b687d73f 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts @@ -122,6 +122,33 @@ describe("cliExecutableResolver", () => { expect(entries).toContain("/opt/homebrew/bin"); }); + it("discovers kimi from ~/.kimi-code/bin when PATH does not include it", () => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-kimi-")); + const homeDir = path.join(tempRoot, "home"); + makeExecutable(path.join(homeDir, ".kimi-code", "bin", executableFileName("kimi"))); + + const realStatSync = fs.statSync; + vi.spyOn(fs, "statSync").mockImplementation(((p: fs.PathLike, opts?: any) => { + const normalizedCandidate = path.normalize(String(p)); + const normalizedTempRoot = path.normalize(tempRoot!); + const candidateBase = path.parse(normalizedCandidate).name.toLowerCase(); + if (candidateBase === "kimi" && !normalizedCandidate.startsWith(normalizedTempRoot)) { + const err: NodeJS.ErrnoException = new Error("ENOENT"); + err.code = "ENOENT"; + throw err; + } + return realStatSync(normalizedCandidate, opts); + }) as typeof fs.statSync); + + expect(resolveExecutableFromKnownLocations("kimi", { + HOME: homeDir, + PATH: "/usr/bin:/bin", + })).toEqual({ + path: path.join(homeDir, ".kimi-code", "bin", executableFileName("kimi")), + source: "known-dir", + }); + }); + it("returns all executable candidates in PATH then known-directory order", () => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-candidates-")); const homeDir = path.join(tempRoot, "home"); diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts index c50642835c..71bfa76f05 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -216,6 +216,10 @@ function getWindowsKnownBinDirs(env: NodeJS.ProcessEnv, command: string): string command === "codex" && localAppData ? path.join(localAppData, "Programs", "OpenAI", "Codex", "bin") : "", + command === "kimi" ? path.join(homeDir, ".kimi-code", "bin") : "", + command === "kimi" && env.KIMI_CODE_HOME?.trim() + ? path.join(env.KIMI_CODE_HOME.trim(), "bin") + : "", ]); } @@ -254,6 +258,10 @@ function getUnixLikeKnownBinDirs(env: NodeJS.ProcessEnv, command: string): strin asdfDataDir ? path.join(asdfDataDir, "shims") : "", ...readNpmPrefixBinDirs(env), command === "codex" ? "/Applications/Codex.app/Contents/Resources" : "", + command === "kimi" ? path.join(homeDir, ".kimi-code", "bin") : "", + command === "kimi" && env.KIMI_CODE_HOME?.trim() + ? path.join(env.KIMI_CODE_HOME.trim(), "bin") + : "", ]); } diff --git a/apps/desktop/src/main/services/ai/codexExecutable.test.ts b/apps/desktop/src/main/services/ai/codexExecutable.test.ts index c3dc710eb0..b2675a757e 100644 --- a/apps/desktop/src/main/services/ai/codexExecutable.test.ts +++ b/apps/desktop/src/main/services/ai/codexExecutable.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -18,6 +18,13 @@ import { loadToolsManifest, } from "../../../../../ade-cli/src/services/tools"; +const emptyToolsRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-codex-empty-tools-")); +afterAll(() => fs.rmSync(emptyToolsRoot, { recursive: true, force: true })); + +function envWithoutCachedTool(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ADE_TOOLS_ROOT: emptyToolsRoot, ...overrides }; +} + /** * Materialize the pinned Codex build into a throwaway cache root the way an * install leaves it. The entry is deliberately created under a concrete @@ -51,9 +58,9 @@ describe("resolveCodexExecutable", () => { verified: true, }, ], - env: { + env: envWithoutCachedTool({ PATH: "/usr/bin:/bin", - }, + }), bundledRoots: [], }), ).toEqual({ @@ -109,9 +116,9 @@ describe("resolveCodexExecutable", () => { verified: true, }, ], - env: { + env: envWithoutCachedTool({ PATH: "/usr/bin:/bin", - }, + }), bundledRoots: [tmpDir], platform: "darwin", arch: "arm64", @@ -145,9 +152,9 @@ describe("resolveCodexExecutable", () => { try { expect( resolveCodexExecutable({ - env: { + env: envWithoutCachedTool({ PATH: "/usr/bin:/bin", - }, + }), bundledRoots: [tmpDir], platform: "darwin", arch: "arm64", diff --git a/apps/desktop/src/main/services/ai/grokPermissionPreflight.test.ts b/apps/desktop/src/main/services/ai/grokPermissionPreflight.test.ts new file mode 100644 index 0000000000..a114eee218 --- /dev/null +++ b/apps/desktop/src/main/services/ai/grokPermissionPreflight.test.ts @@ -0,0 +1,271 @@ +/** + * Grok permission preflight. + * + * The log fragments below are verbatim captures from live Grok 1.0.13 on + * 2026-08-31, taken against a fake `HOME` whose `~/.claude/settings.json` + * contained ONLY `{"permissions":{"defaultMode":"auto"}}` — the case the old + * `grok inspect` parser was blind to, and which drove a real ACP session to + * zero permission requests and a completed write. Same binary, same argv, same + * cwd; the only difference is `_GROK_CLAUDE_MARKER_OVERRIDE`. + * + * Every test that is not the happy path asserts `ok: false`. There is no input + * that may turn silence into a pass. + */ + +import { beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + checkGrokPermissionNeutralization, + classifyGrokAttestLog, + getCachedGrokPermissionPreflight, + GROK_AUTO_SEEDED_MARKER, + GROK_COMPAT_DISABLED_MARKER, + resetGrokPermissionPreflightCache, + withGrokDebugLogging, + type RunGrokAttestProbe, +} from "./grokPermissionPreflight"; +import { grokDialect } from "../chat/acpHost/acpDialects"; +import { GROK_CLAUDE_MARKER_OVERRIDE_ENV } from "../../../shared/grokSupervision"; +import type { AcpSpawnPlan } from "../chat/acpHost/acpHostTypes"; + +/** Live capture WITHOUT the kill switch: Claude's defaultMode seeded auto. */ +const LOG_LEAKING = ` +2026-08-31T17:55:20.101010Z INFO xai_grok_workspace::config: loaded config +2026-08-31T17:55:26.141930Z INFO xai_grok_workspace::permission::manager: auto permission mode seeded from Claude defaultMode / prompt_policy +2026-08-31T17:55:26.200000Z INFO run_stdio_agent: session ready +`; + +/** Live capture WITH the kill switch: the hatch fired, nothing was seeded. */ +const LOG_NEUTRALIZED = ` +2026-08-31T17:55:27.101010Z INFO xai_grok_workspace::config: loaded config +2026-08-31T17:55:27.151465Z INFO run_stdio_agent: xai_grok_workspace::permission::claude_settings: Claude compat disabled (marker set in config.toml) first_gate="load_claude_env_with_project" +2026-08-31T17:55:27.200000Z INFO run_stdio_agent: session ready +`; + +const PLAN: AcpSpawnPlan = grokDialect.buildSpawnPlan({ + binaryPath: "/bin/grok", + cwd: "/lane/worktree", + baseEnv: { PATH: "/bin" }, +}); + +function runReturning(logText: string, version: string | null = "1.0.13"): RunGrokAttestProbe { + return async () => ({ ok: true, logText, version }); +} + +beforeEach(() => { + resetGrokPermissionPreflightCache(); +}); + +describe("grok debug spawn plan", () => { + it("puts the debug flags before the agent subcommand, where global flags go", () => { + const plan = withGrokDebugLogging(PLAN, "/tmp/probe.log"); + expect(plan.args.indexOf("--debug")).toBeLessThan(plan.args.indexOf("agent")); + expect(plan.args.indexOf("--debug-file")).toBeLessThan(plan.args.indexOf("agent")); + expect(plan.args[plan.args.indexOf("--debug-file") + 1]).toBe("/tmp/probe.log"); + expect(plan.args.at(-1)).toBe("stdio"); + }); + + it("copies the session's own argv and environment verbatim", () => { + // A probe run against a different process than the session would prove + // nothing, so everything except the debug flags must survive untouched. + const plan = withGrokDebugLogging(PLAN, "/tmp/probe.log"); + expect(plan.env[GROK_CLAUDE_MARKER_OVERRIDE_ENV]).toBe("1"); + expect(plan.env.PATH).toBe("/bin"); + expect(plan.cwd).toBe("/lane/worktree"); + expect(plan.args).toEqual(expect.arrayContaining(["--permission-mode", "default"])); + expect(plan.args.filter((arg) => arg === "--debug")).toHaveLength(1); + }); +}); + +describe("grok self-attestation", () => { + it("passes only when the hatch fired and nothing was seeded", () => { + expect(classifyGrokAttestLog(LOG_NEUTRALIZED, "1.0.13")).toMatchObject({ + ok: true, + status: "neutralized", + compatDisabledHits: 1, + autoSeededHits: 0, + }); + }); + + it("catches the defaultMode-only leak that grok inspect cannot see", () => { + // The regression this whole module was rewritten for: `grok inspect` printed + // `Source: (none)` / `0 loaded` for this machine, byte-identical to a clean + // one, while the session did zero permission requests and wrote the file. + expect(classifyGrokAttestLog(LOG_LEAKING, "1.0.13")).toMatchObject({ + ok: false, + status: "claude-import-active", + autoSeededHits: 1, + }); + }); + + it("fails when the attestation line is renamed away", () => { + // The log strings are no more contractual than the env var. If xAI renames + // one, the positive proof disappears and ADE must degrade, not assume. + const renamed = LOG_NEUTRALIZED.replace( + GROK_COMPAT_DISABLED_MARKER, + "Claude compatibility layer switched off", + ); + expect(classifyGrokAttestLog(renamed, "1.0.13")).toMatchObject({ + ok: false, + status: "marker-not-honored", + compatDisabledHits: 0, + }); + }); + + it("fails when the hatch is removed entirely, which is the regression it detects", () => { + const noHatch = LOG_NEUTRALIZED.replace(/.*Claude compat disabled.*\n/, ""); + expect(classifyGrokAttestLog(noHatch, "1.0.13").ok).toBe(false); + }); + + it("fails on an empty or missing debug log rather than assuming silence is good", () => { + expect(classifyGrokAttestLog("", null)).toMatchObject({ ok: false, status: "unparsable" }); + expect(classifyGrokAttestLog(" \n ", null)).toMatchObject({ ok: false, status: "unparsable" }); + }); + + it("reports the leak even when both lines somehow appear", () => { + // Contradictory evidence resolves toward the observed harm, not the claim. + const both = `${LOG_NEUTRALIZED}\n${LOG_LEAKING}`; + expect(classifyGrokAttestLog(both, "1.0.13")).toMatchObject({ + ok: false, + status: "claude-import-active", + }); + }); + + it("never passes any input that lacks the attestation", () => { + for (const sample of ["", "random noise", GROK_AUTO_SEEDED_MARKER, "INFO session ready"]) { + expect(classifyGrokAttestLog(sample, null).ok).toBe(false); + } + }); +}); + +describe("grok permission preflight", () => { + it("verifies with the probe and passes a neutralized machine", async () => { + const result = await checkGrokPermissionNeutralization({ + spawnPlan: PLAN, + run: runReturning(LOG_NEUTRALIZED), + }); + expect(result).toMatchObject({ ok: true, status: "neutralized", version: "1.0.13" }); + }); + + it("treats a probe that cannot run as unverified rather than as a pass", async () => { + const result = await checkGrokPermissionNeutralization({ + spawnPlan: PLAN, + run: async () => ({ ok: false, error: "spawn ENOENT" }), + }); + expect(result).toMatchObject({ ok: false, status: "probe-failed", detail: "spawn ENOENT" }); + }); + + it("treats a probe timeout as unverified rather than as a pass", async () => { + const result = await checkGrokPermissionNeutralization({ + spawnPlan: PLAN, + run: async ({ timeoutMs }) => ({ ok: false, error: `handshake exceeded ${timeoutMs}ms` }), + timeoutMs: 25, + }); + expect(result).toMatchObject({ ok: false, status: "probe-failed" }); + expect(result.detail).toContain("25ms"); + }); + + it("survives a runner that throws instead of returning a failure", async () => { + const result = await checkGrokPermissionNeutralization({ + spawnPlan: PLAN, + run: async () => { + throw new Error("boom"); + }, + }); + expect(result).toMatchObject({ ok: false, status: "probe-failed", detail: "boom" }); + }); + + it("deletes the debug log once it has the verdict", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-grok-preflight-test-")); + try { + let seenPath = ""; + await checkGrokPermissionNeutralization({ + spawnPlan: PLAN, + debugDir: dir, + run: async ({ debugFilePath }) => { + seenPath = debugFilePath; + // Stand in for the agent writing its log. + fs.writeFileSync(debugFilePath, LOG_NEUTRALIZED, "utf8"); + return { ok: true, logText: LOG_NEUTRALIZED, version: "1.0.13" }; + }, + }); + expect(seenPath.startsWith(dir)).toBe(true); + expect(fs.existsSync(seenPath)).toBe(false); + expect(fs.readdirSync(dir)).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("deletes the debug log even when the probe fails", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-grok-preflight-test-")); + try { + await checkGrokPermissionNeutralization({ + spawnPlan: PLAN, + debugDir: dir, + run: async ({ debugFilePath }) => { + fs.writeFileSync(debugFilePath, "partial", "utf8"); + return { ok: false, error: "crashed" }; + }, + }); + expect(fs.readdirSync(dir)).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("probes once per binary, cwd and argv, then answers from cache", async () => { + let calls = 0; + const run: RunGrokAttestProbe = async () => { + calls += 1; + return { ok: true, logText: LOG_NEUTRALIZED, version: "1.0.13" }; + }; + await checkGrokPermissionNeutralization({ spawnPlan: PLAN, run }); + await checkGrokPermissionNeutralization({ spawnPlan: PLAN, run }); + expect(calls).toBe(1); + expect(getCachedGrokPermissionPreflight(PLAN)?.ok).toBe(true); + + // A different lane is a different answer: Grok resolves project-scoped + // rules against the cwd. + await checkGrokPermissionNeutralization({ spawnPlan: { ...PLAN, cwd: "/other/lane" }, run }); + expect(calls).toBe(2); + + // A different permission mode rides argv, so it earns its own verdict. + const yoloPlan = grokDialect.buildSpawnPlan({ + binaryPath: "/bin/grok", + cwd: "/lane/worktree", + baseEnv: { PATH: "/bin" }, + permissionMode: "yolo", + }); + await checkGrokPermissionNeutralization({ spawnPlan: yoloPlan, run }); + expect(calls).toBe(3); + }); + + it("re-probes when the caller forces it", async () => { + let calls = 0; + const run: RunGrokAttestProbe = async () => { + calls += 1; + return { ok: true, logText: LOG_NEUTRALIZED, version: "1.0.13" }; + }; + await checkGrokPermissionNeutralization({ spawnPlan: PLAN, run }); + await checkGrokPermissionNeutralization({ spawnPlan: PLAN, run, force: true }); + expect(calls).toBe(2); + }); + + it("shares one in-flight probe between concurrent opens", async () => { + let calls = 0; + const run: RunGrokAttestProbe = async () => { + calls += 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + return { ok: true, logText: LOG_NEUTRALIZED, version: "1.0.13" }; + }; + const [a, b] = await Promise.all([ + checkGrokPermissionNeutralization({ spawnPlan: PLAN, run }), + checkGrokPermissionNeutralization({ spawnPlan: PLAN, run }), + ]); + expect(calls).toBe(1); + expect(a).toEqual(b); + }); +}); diff --git a/apps/desktop/src/main/services/ai/grokPermissionPreflight.ts b/apps/desktop/src/main/services/ai/grokPermissionPreflight.ts new file mode 100644 index 0000000000..656c4bf293 --- /dev/null +++ b/apps/desktop/src/main/services/ai/grokPermissionPreflight.ts @@ -0,0 +1,444 @@ +/** + * Make Grok attest, in its own words, that ADE took its approvals back. + * + * `shared/grokSupervision.ts` explains the two-half neutralization. Both halves + * lean on `_GROK_CLAUDE_MARKER_OVERRIDE`, an undocumented, underscore-prefixed + * vendor hatch in a binary that ships roughly daily. Trusting it silently is + * exactly the failure this whole change exists to fix, so ADE asks Grok itself + * before it opens a session. + * + * ## Why this does NOT parse `grok inspect` + * + * It used to, and that gate FAILED OPEN on the precise root cause spec §3 + * documents. `inspect` builds its `Permissions` rows from per-rule provenance + * (`tag_with_source` over `config.rules`), so a `~/.claude/settings.json` + * holding only `{"permissions":{"defaultMode":"auto"}}` contributes zero rules + * and therefore zero rows — while still setting `prompt_policy: Auto`. + * Measured live on 1.0.13 against fake `HOME`s under /tmp: + * + * defaultMode only -> Source: (none) 0 loaded + * defaultMode + 1 rule -> Source: .../settings.json 1 loaded + * rule only -> Source: .../settings.json 1 loaded + * + * The first row is byte-identical to a fully neutralized machine. A parser can + * not tell them apart, and the same settings file drove a real ACP session to + * 0 permission requests and a completed write. Three static pre-checks in a row + * have now been wrong (single-source parse, print-order dependence, and this), + * which is why the load-bearing safety net is the RUNTIME invariant in + * `acpHost/acpSupervisionGuard.ts`. This preflight is an early warning. It is + * never proof of supervision. + * + * ## What it does instead + * + * Spawns one throwaway agent process with the session's exact argv and + * environment plus `--debug --debug-file`, runs `initialize` + `session/new`, + * and reads two tracing lines out of the debug log. Verified live on 1.0.13 + * with a `defaultMode`-only settings file — the case `inspect` is blind to: + * + * no marker -> "Claude compat disabled" x0, "auto permission mode seeded" x1 + * marker -> "Claude compat disabled" x1, "auto permission mode seeded" x0 + * + * - `auto permission mode seeded from Claude defaultMode / prompt_policy` + * (`permission/manager/mod.rs`) reports the ACTUAL manager state, so it sees + * the case that has no rules to enumerate. + * - `Claude compat disabled (marker set in config.toml)` is positive proof the + * hatch fired on THIS binary. Measured at 1 for every marker run regardless + * of whether the machine has Claude settings at all, so its absence is + * meaningful. That makes this signal a live regression detector: if xAI + * renames or removes `_GROK_CLAUDE_MARKER_OVERRIDE`, the attestation + * disappears and ADE degrades loudly instead of silently losing supervision. + * + * Both conditions fail SAFE. A renamed string, an empty log, a crash, or a + * timeout all read as "not neutralized". There is no input that turns silence + * into a pass. + * + * ## No prompt, no spend, no user content + * + * The probe sends `initialize` and `session/new` and then closes. It never + * sends `session/prompt`, so it costs nothing and the debug log contains + * handshake tracing only — measured at ~32 KB with zero occurrences of any + * settings or instruction file content. This is also why `--debug` is NOT put + * on the user's real session: debug logs of a real turn would carry prompts, + * file contents, and tool arguments to disk. + * + * `inspect` does not honor `--debug-file` (it initializes no logger), so the + * agent spawn is the only place this signal exists — which is the better place + * anyway, because it is the code path the session actually uses. + * + * ## Accepted residue + * + * `session/new` materializes a session directory that `session/close` does not + * remove, so each probe leaves ~13.7 KB under + * `$GROK_HOME/sessions///`. Containing it would mean + * pointing the probe at a private `GROK_HOME`, which would stop it exercising + * the user's real `~/.grok/config.toml` — including `[ui] permission_mode`, + * one of the two halves under test — so the residue is the cheaper trade, and + * ADE must not delete from `~/.grok` either. The cache bounds it to roughly + * once per lane per Grok version, and `grok sessions list` does not show it. + * + * If Grok session disk-adopt or session import is ever built, it MUST skip + * these: they live beside real sessions and are not filtered by whatever hides + * them from `sessions list`. Measured discriminator: `events.jsonl` is exactly + * 0 bytes and `chat_history.jsonl` holds only the system entry. + * + * ## Caching + * + * Keyed by binary path + cwd + argv, invalidated by the binary's size and + * mtime — a `grok` upgrade rewrites the file, so that pair is a version + * fingerprint that costs a `stat` instead of another spawn. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { createAcpConnection, initializeAcpConnection } from "../chat/acpHost/acpConnection"; +import { grokDialect } from "../chat/acpHost/acpDialects"; +import { ACP_METHOD } from "../chat/acpHost/acpProtocolTypes"; +import type { AcpSpawnPlan } from "../chat/acpHost/acpHostTypes"; +import type { Logger } from "../logging/logger"; + +/** Budget for the whole probe: spawn, handshake, session/new, close. */ +export const GROK_PREFLIGHT_TIMEOUT_MS = 20_000; + +/** Refresh window even when the binary fingerprint has not moved. */ +export const GROK_PREFLIGHT_TTL_MS = 10 * 60_000; + +/** Never read more than this from a debug log, however large it grew. */ +export const GROK_PREFLIGHT_LOG_READ_CAP_BYTES = 4 * 1024 * 1024; + +/** Positive attestation that the Claude-import kill switch fired. */ +export const GROK_COMPAT_DISABLED_MARKER = "Claude compat disabled"; + +/** Evidence that Claude's `permissions.defaultMode` seeded the auto classifier. */ +export const GROK_AUTO_SEEDED_MARKER = "auto permission mode seeded"; + +export type GrokPermissionPreflightStatus = + /** The hatch fired and no Claude auto-mode was seeded. The only pass. */ + | "neutralized" + /** Grok reported seeding auto permission mode from Claude. The root cause. */ + | "claude-import-active" + /** No attestation line. The hatch was renamed, removed, or never applied. */ + | "marker-not-honored" + /** The probe ran but produced no readable debug log. */ + | "unparsable" + /** The probe could not run: missing binary, crash, timeout. */ + | "probe-failed"; + +export type GrokPermissionPreflight = { + /** True only for a verified `neutralized`. Everything else degrades loudly. */ + ok: boolean; + status: GrokPermissionPreflightStatus; + /** Times Grok said the Claude import was disabled. Expected: 1. */ + compatDisabledHits: number; + /** Times Grok said it seeded auto mode from Claude. Expected: 0. */ + autoSeededHits: number; + /** Agent version the handshake reported, for diagnostics. */ + version: string | null; + /** One line, safe to log. Never shown verbatim to the user. */ + detail: string; +}; + +type CacheEntry = { + checkedAtMs: number; + /** Binary fingerprint the entry was measured against. */ + fingerprint: string; + result: GrokPermissionPreflight; +}; + +const preflightCache = new Map(); +const inFlight = new Map>(); + +function cacheKey(plan: AcpSpawnPlan): string { + // argv is part of the identity: `--permission-mode` rides it, and a chat that + // changed posture deserves its own verdict. + return `${plan.command}\u0000${plan.cwd}\u0000${JSON.stringify(plan.args)}`; +} + +/** + * Cheap stand-in for the binary version. + * + * `grok --version` would be authoritative but costs a spawn on every cache + * lookup, which defeats the point of the cache. Size and mtime move on every + * upgrade, so they invalidate exactly when a version check would. + */ +function binaryFingerprint(binaryPath: string): string { + try { + const stat = fs.statSync(binaryPath); + return `${stat.size}:${Math.trunc(stat.mtimeMs)}`; + } catch { + // A path we cannot stat cannot be fingerprinted. A fixed marker keeps the + // cache usable; the probe itself fails loudly if the binary is really gone. + return "unstattable"; + } +} + +/** + * Insert `--debug --debug-file ` into a Grok spawn plan. + * + * Both are GLOBAL flags, so they must land before the `agent` subcommand — the + * same positional rule the dialect documents for `--no-auto-update`. Everything + * else is copied verbatim so the probe measures the process the session will + * actually run, including the kill switch already present in `plan.env`. + */ +export function withGrokDebugLogging(plan: AcpSpawnPlan, debugFilePath: string): AcpSpawnPlan { + const agentIndex = plan.args.indexOf("agent"); + const insertAt = agentIndex >= 0 ? agentIndex : plan.args.length; + const args = [ + ...plan.args.slice(0, insertAt), + "--debug", + "--debug-file", + debugFilePath, + ...plan.args.slice(insertAt), + ]; + return { ...plan, args }; +} + +/** Read a debug log without letting a pathological file into memory. */ +function readCappedLog(filePath: string): string { + let handle: number | null = null; + try { + const stat = fs.statSync(filePath); + const length = Math.min(stat.size, GROK_PREFLIGHT_LOG_READ_CAP_BYTES); + if (length <= 0) return ""; + handle = fs.openSync(filePath, "r"); + const buffer = Buffer.allocUnsafe(length); + fs.readSync(handle, buffer, 0, length, 0); + return buffer.toString("utf8"); + } catch { + return ""; + } finally { + if (handle !== null) { + try { + fs.closeSync(handle); + } catch { + // Nothing to do; the delete below is what matters. + } + } + } +} + +/** Best-effort delete. The log must not outlive the verdict. */ +function removeQuietly(filePath: string): void { + try { + fs.rmSync(filePath, { force: true }); + } catch { + // A log ADE cannot delete is not a reason to fail a session. + } +} + +function countOccurrences(haystack: string, needle: string): number { + if (!haystack.length) return 0; + return haystack.split(needle).length - 1; +} + +/** Turn the probe's debug log into a verdict. Pure; the runner calls it. */ +export function classifyGrokAttestLog(logText: string, version: string | null): GrokPermissionPreflight { + const compatDisabledHits = countOccurrences(logText, GROK_COMPAT_DISABLED_MARKER); + const autoSeededHits = countOccurrences(logText, GROK_AUTO_SEEDED_MARKER); + + if (!logText.trim().length) { + return { + ok: false, + status: "unparsable", + compatDisabledHits: 0, + autoSeededHits: 0, + version, + detail: "Grok produced no debug log, so ADE could not confirm its permission state.", + }; + } + // Checked first: this is the harm itself, not a missing attestation about it. + if (autoSeededHits > 0) { + return { + ok: false, + status: "claude-import-active", + compatDisabledHits, + autoSeededHits, + version, + detail: `Grok seeded auto permission mode from the user's Claude settings (${autoSeededHits} time(s)).`, + }; + } + // No positive attestation means the hatch did not fire, or its log line was + // renamed. Either way ADE has not verified anything, so it is not a pass. + if (compatDisabledHits === 0) { + return { + ok: false, + status: "marker-not-honored", + compatDisabledHits, + autoSeededHits, + version, + detail: + "Grok never reported disabling its Claude settings import, so ADE could not confirm " + + "the _GROK_CLAUDE_MARKER_OVERRIDE hatch still works on this build.", + }; + } + return { + ok: true, + status: "neutralized", + compatDisabledHits, + autoSeededHits, + version, + detail: "Grok reported its Claude settings import disabled and seeded no auto permission mode.", + }; +} + +export type GrokAttestProbeResult = + | { ok: true; logText: string; version: string | null } + | { ok: false; error: string }; + +export type RunGrokAttestProbe = (args: { + /** Spawn plan with the debug flags already spliced in. */ + spawnPlan: AcpSpawnPlan; + debugFilePath: string; + timeoutMs: number; +}) => Promise; + +/** + * Default runner: one throwaway `agent stdio` process, handshake only. + * + * `session/close` is sent so the agent tears its own session down rather than + * leaving it in `active_sessions.json`. The debug file is read and deleted here + * on every exit path, including the failing ones. + */ +const spawnGrokAttestProbe: RunGrokAttestProbe = async ({ spawnPlan, debugFilePath, timeoutMs }) => { + const connection = createAcpConnection({ dialect: grokDialect, spawnPlan }); + try { + const { response } = await initializeAcpConnection({ + connection, + dialect: grokDialect, + timeoutMs, + }); + const version = response.agentInfo?.version ?? null; + const session = await connection.request<{ sessionId?: string }>( + ACP_METHOD.sessionNew, + { cwd: spawnPlan.cwd, mcpServers: [] }, + { timeoutMs }, + ); + if (session?.sessionId) { + // Best effort. A session ADE could not close is not a failed verdict; the + // process is about to be killed anyway. + await connection + .request(ACP_METHOD.sessionClose, { sessionId: session.sessionId }, { timeoutMs: 5_000 }) + .catch(() => undefined); + } + return { ok: true, logText: readCappedLog(debugFilePath), version }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } finally { + connection.dispose("grok permission preflight finished"); + removeQuietly(debugFilePath); + } +}; + +export type CheckGrokPermissionNeutralizationArgs = { + /** + * The plan the session itself will spawn with. The probe copies it verbatim + * and only adds the debug flags, so it can never measure a different process + * than the one ADE is about to run. + */ + spawnPlan: AcpSpawnPlan; + logger?: Pick; + force?: boolean; + /** Test seam. Replaces the probe spawn. */ + run?: RunGrokAttestProbe; + timeoutMs?: number; + /** Test seam. Directory for the throwaway debug log. */ + debugDir?: string; +}; + +export function resetGrokPermissionPreflightCache(): void { + preflightCache.clear(); +} + +/** Read a cached verdict without spawning anything. */ +export function getCachedGrokPermissionPreflight(plan: AcpSpawnPlan): GrokPermissionPreflight | null { + const entry = preflightCache.get(cacheKey(plan)); + if (!entry) return null; + if (entry.fingerprint !== binaryFingerprint(plan.command)) return null; + if (Date.now() - entry.checkedAtMs >= GROK_PREFLIGHT_TTL_MS) return null; + return entry.result; +} + +/** + * Ask Grok whether ADE's neutralization took. + * + * Never throws and never blocks a session: a failed probe is a `false`, not an + * exception. The caller shows the honest-degradation notice instead. + */ +export async function checkGrokPermissionNeutralization( + args: CheckGrokPermissionNeutralizationArgs, +): Promise { + const key = cacheKey(args.spawnPlan); + const fingerprint = binaryFingerprint(args.spawnPlan.command); + + if (!args.force) { + const cached = getCachedGrokPermissionPreflight(args.spawnPlan); + if (cached) return cached; + const existing = inFlight.get(key); + if (existing) return existing; + } + + const run = args.run ?? spawnGrokAttestProbe; + // OS temp, never the user's home and never `~/.grok`. Unique per probe so two + // concurrent lanes cannot read each other's log. + const debugFilePath = path.join( + args.debugDir ?? os.tmpdir(), + `ade-grok-preflight-${randomUUID()}.log`, + ); + + const probe = (async (): Promise => { + let outcome: GrokAttestProbeResult; + try { + outcome = await run({ + spawnPlan: withGrokDebugLogging(args.spawnPlan, debugFilePath), + debugFilePath, + timeoutMs: args.timeoutMs ?? GROK_PREFLIGHT_TIMEOUT_MS, + }); + } catch (error) { + outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + if (!outcome.ok) { + return { + ok: false, + status: "probe-failed", + compatDisabledHits: 0, + autoSeededHits: 0, + version: null, + detail: outcome.error, + }; + } + return classifyGrokAttestLog(outcome.logText, outcome.version); + })() + .then((result) => { + preflightCache.set(key, { checkedAtMs: Date.now(), fingerprint, result }); + return result; + }) + .finally(() => { + // Belt and braces. The default runner already deletes it; a custom runner + // or an early throw must not leave a log behind either. + removeQuietly(debugFilePath); + }); + + inFlight.set(key, probe); + try { + const result = await probe; + if (result.ok) { + args.logger?.info?.("ai.grok_permission_preflight.ok", { + cwd: args.spawnPlan.cwd, + version: result.version, + }); + } else { + args.logger?.warn?.("ai.grok_permission_preflight.failed", { + cwd: args.spawnPlan.cwd, + status: result.status, + version: result.version, + compatDisabledHits: result.compatDisabledHits, + autoSeededHits: result.autoSeededHits, + detail: result.detail, + }); + } + return result; + } finally { + inFlight.delete(key); + } +} diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index bd229e372a..b10e318e7a 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -15,9 +15,14 @@ import { } from "../../../shared/providerPlatformSupport"; import type { PiInstallation, PiProfileInventory } from "./piInstallation"; import { nowIso } from "../shared/utils"; +import { + ACP_PROVIDER_IDS, + ACP_PROVIDER_METADATA, + type AcpProviderId, +} from "../../../shared/acpProviderMetadata"; function createUnavailableStatus( - provider: "claude" | "codex" | "cursor" | "droid" | "pi", + provider: "claude" | "codex" | "cursor" | "droid" | "pi" | AcpProviderId, checkedAt: string, ): AiProviderConnectionStatus { return { @@ -393,5 +398,51 @@ export async function buildProviderConnections( : undefined; if (pi) applyRuntimeHealth(pi, piRuntimeHealth); - return pi ? { claude, codex, cursor, droid, pi } : { claude, codex, cursor, droid }; + const acpConnections: Partial> = {}; + for (const provider of ACP_PROVIDER_IDS) { + const metadata = ACP_PROVIDER_METADATA[provider]; + const cli = cliStatuses.find((status) => status.cli === provider) ?? null; + const runtimeDetected = Boolean(cli?.installed); + const authAvailable = Boolean(cli?.installed && cli.authenticated); + const health = getProviderRuntimeHealth(provider); + const status: AiProviderConnectionStatus = { + ...createUnavailableStatus(provider, checkedAt), + authAvailable, + runtimeDetected, + runtimeAvailable: authAvailable && runtimeDetected, + // No dialect reports usage through a channel ADE polls yet. Kimi does not + // report it at all; the rest fold it into prompt results, which only the + // ACP host sees. + usageAvailable: false, + path: cli?.path ?? null, + sources: [ + { + kind: "cli", + detected: runtimeDetected, + authenticated: cli?.authenticated, + // Always false for these: a credential file on disk is not proof the + // credential still works, and saying otherwise would make a stale + // login read as connected. + verified: cli?.verified, + path: cli?.path ?? null, + }, + ], + blocker: !runtimeDetected + ? `The ${metadata.statusLabel} CLI (\`${provider}\`) was not found on this machine.` + : !authAvailable + ? `${metadata.statusLabel} is installed but no login was detected. Run: ${metadata.loginHint}` + : null, + }; + applyRuntimeHealth(status, health); + acpConnections[provider] = status; + } + + return { + claude, + codex, + cursor, + droid, + ...(pi ? { pi } : {}), + ...acpConnections, + }; } diff --git a/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts b/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts index e2f438b4b9..12f96616c0 100644 --- a/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts +++ b/apps/desktop/src/main/services/ai/providerRuntimeHealth.ts @@ -1,7 +1,15 @@ import { nowIso } from "../shared/utils"; export type ProviderRuntimeHealthState = "ready" | "auth-failed" | "runtime-failed"; -export type ProviderRuntimeHealthProvider = "claude" | "codex" | "cursor" | "pi"; +export type ProviderRuntimeHealthProvider = + | "claude" + | "codex" + | "cursor" + | "pi" + | "qwen" + | "kimi" + | "grok" + | "copilot"; export type ProviderRuntimeHealth = { provider: ProviderRuntimeHealthProvider; diff --git a/apps/desktop/src/main/services/ai/qwenUserSettings.test.ts b/apps/desktop/src/main/services/ai/qwenUserSettings.test.ts new file mode 100644 index 0000000000..8dee5fcb3f --- /dev/null +++ b/apps/desktop/src/main/services/ai/qwenUserSettings.test.ts @@ -0,0 +1,75 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadQwenUserSettings, parseQwenUserSettings } from "./qwenUserSettings"; + +describe("parseQwenUserSettings", () => { + it("does not treat a settings file with only MCP servers as signed in", () => { + expect(parseQwenUserSettings({ + mcpServers: { unityMCP: { url: "http://127.0.0.1:8080/mcp" } }, + })).toEqual({ + authenticated: false, + models: [], + defaultModelId: null, + }); + }); + + it("reads a custom OpenAI provider the Qwen CLI saved, without returning the key", () => { + const parsed = parseQwenUserSettings({ + env: { QWEN_CUSTOM_API_KEY_OPENAI_HTTP_LOCALHOST_8317: "dummy" }, + modelProviders: { + openai: [{ + id: "gpt-5.5", + name: "gpt-5.5", + baseUrl: "http://localhost:8317/v1", + envKey: "QWEN_CUSTOM_API_KEY_OPENAI_HTTP_LOCALHOST_8317", + }], + }, + security: { auth: { selectedType: "openai" } }, + model: { name: "gpt-5.5", baseUrl: "http://localhost:8317/v1" }, + }); + expect(parsed.authenticated).toBe(true); + expect(parsed.defaultModelId).toBe("gpt-5.5"); + expect(parsed.models).toEqual([{ id: "gpt-5.5", displayName: "gpt-5.5" }]); + expect(JSON.stringify(parsed)).not.toMatch(/dummy/i); + }); + + it("does not treat an OpenAI selection without a key as signed in", () => { + expect(parseQwenUserSettings({ + security: { auth: { selectedType: "openai" } }, + model: { name: "coder-model", baseUrl: "https://openrouter.ai/api/v1" }, + }).authenticated).toBe(false); + }); +}); + +describe("loadQwenUserSettings", () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + it("reads settings.json from QWEN_HOME", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ade-qwen-settings-")); + dirs.push(root); + mkdirSync(root, { recursive: true }); + writeFileSync(path.join(root, "settings.json"), `${JSON.stringify({ + security: { auth: { selectedType: "openai", apiKey: "sk-test" } }, + model: { name: "gpt-5.5" }, + })}\n`); + const loaded = await loadQwenUserSettings({ env: { QWEN_HOME: root } }); + expect(loaded.authenticated).toBe(true); + expect(loaded.defaultModelId).toBe("gpt-5.5"); + expect(JSON.stringify(loaded)).not.toMatch(/sk-test/); + }); + + it("returns empty when the file is missing", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ade-qwen-settings-missing-")); + dirs.push(root); + await expect(loadQwenUserSettings({ env: { QWEN_HOME: root } })).resolves.toEqual({ + authenticated: false, + models: [], + defaultModelId: null, + }); + }); +}); diff --git a/apps/desktop/src/main/services/ai/qwenUserSettings.ts b/apps/desktop/src/main/services/ai/qwenUserSettings.ts new file mode 100644 index 0000000000..d5636e9223 --- /dev/null +++ b/apps/desktop/src/main/services/ai/qwenUserSettings.ts @@ -0,0 +1,107 @@ +/** + * Read the Qwen CLI's own settings without spawning it. + * + * ADE does not configure Qwen. Users sign in (or point it at an + * OpenAI-compatible server) inside the Qwen CLI. This module is how ADE + * notices that work: `~/.qwen/settings.json` (or `$QWEN_HOME/settings.json`) + * holds the custom provider, the selected model, and the env-key slot the + * CLI uses for the API key. + * + * Never return the key itself. Callers need "is there a key" and "which + * model ids did they configure". + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { qwenConfigHome } from "../shared/providerConfigHomes"; + +export type QwenSettingsModel = { + id: string; + displayName: string; +}; + +export type QwenUserSettings = { + authenticated: boolean; + models: QwenSettingsModel[]; + defaultModelId: string | null; +}; + +const EMPTY: QwenUserSettings = { + authenticated: false, + models: [], + defaultModelId: null, +}; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function trimmedString(value: unknown): string | null { + if (typeof value !== "string") return null; + const next = value.trim(); + return next.length ? next : null; +} + +function openaiProviders(settings: Record): Array> { + const providers = asRecord(settings.modelProviders); + const openai = providers?.openai; + if (!Array.isArray(openai)) return []; + return openai.map(asRecord).filter((entry): entry is Record => entry !== null); +} + +/** + * Parse a Qwen `settings.json` object. Used by tests and by the disk reader. + * Ignores unknown keys so a newer CLI schema cannot crash ADE. + */ +export function parseQwenUserSettings(raw: unknown): QwenUserSettings { + const settings = asRecord(raw); + if (!settings) return EMPTY; + + const security = asRecord(settings.security); + const auth = asRecord(security?.auth); + const env = asRecord(settings.env) ?? {}; + const providers = openaiProviders(settings); + const model = asRecord(settings.model); + + const hasInlineApiKey = Boolean(trimmedString(auth?.apiKey)); + const hasProviderKey = providers.some((entry) => { + const envKey = trimmedString(entry.envKey); + return Boolean(envKey && trimmedString(env[envKey])); + }); + const models: QwenSettingsModel[] = []; + const seen = new Set(); + const push = (id: string, displayName: string) => { + if (seen.has(id)) return; + seen.add(id); + models.push({ id, displayName }); + }; + + const defaultModelId = trimmedString(model?.name); + if (defaultModelId) push(defaultModelId, defaultModelId); + for (const entry of providers) { + const id = trimmedString(entry.id) ?? trimmedString(entry.name); + if (!id) continue; + push(id, trimmedString(entry.name) ?? id); + } + + return { + authenticated: hasInlineApiKey || hasProviderKey, + models, + defaultModelId, + }; +} + +/** Load Qwen's settings from its config home. Missing or unreadable files are empty, not errors. */ +export async function loadQwenUserSettings(args: { + env?: NodeJS.ProcessEnv; + homeDir?: string; +} = {}): Promise { + const root = qwenConfigHome(args); + try { + const raw = JSON.parse(await readFile(path.join(root, "settings.json"), "utf8")) as unknown; + return parseQwenUserSettings(raw); + } catch { + return EMPTY; + } +} diff --git a/apps/desktop/src/main/services/chat/acpHost/acpConnection.ts b/apps/desktop/src/main/services/chat/acpHost/acpConnection.ts new file mode 100644 index 0000000000..5cc66020eb --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpConnection.ts @@ -0,0 +1,553 @@ +/** + * ACP transport: one child process, NDJSON JSON-RPC over its stdio. + * + * ## Why a direct child process and not a forked worker + * + * `droidSdkPool.ts` and `piSdkPool.ts` fork a Node worker. They do that because + * each wraps a vendor SDK: a large ESM library with its own transport, its own + * lifecycle, and its own crash modes. The worker gives that library a process + * to fail in, and it keeps an ESM dependency out of the CommonJS main bundle. + * + * ACP has no library to isolate. It is a line-delimited JSON-RPC dialogue with + * a process ADE already spawns. A forked worker would add a second process, a + * second IPC hop, and a second serialization of every stream chunk, and it + * would put the process-tree kill one level further from the code that needs + * it. The agent process is already the isolation boundary. So this module + * spawns the agent and speaks the protocol in the main process. + * + * The house rules from those two pools still apply, and this module keeps them: + * pending requests reject on exit, stderr goes to the logger, disposal uses + * `terminateChildProcessTree`, and the pool holds a generation counter. + * + * ## Windows parity + * + * `resolveCliSpawnInvocation` rewrites a `.cmd`, `.bat`, or `.ps1` shim into + * the form Node will spawn after CVE-2024-27980. `windowsHide` keeps the + * console window hidden. `terminateChildProcessTree` uses `taskkill /T /F` on + * win32 and the process group elsewhere. Prompt text never rides the command + * line here; it always travels over stdio. + */ + +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { Logger } from "../../logging/logger"; +import { resolveCliSpawnInvocation } from "../../shared/processExecution"; +import { terminateChildProcessTree } from "../../shared/utils"; +import { + ACP_METHOD, + ACP_PROTOCOL_VERSION, + ACP_RPC_METHOD_NOT_FOUND, + normalizeAcpRpcError, + normalizeAcpRpcFrame, + normalizeAcpRpcId, + normalizeAcpSessionNotification, + type AcpClientCapabilities, + type AcpInitializeResponse, + type AcpRpcErrorPayload, + type AcpRpcId, + type AcpSessionNotification, +} from "./acpProtocolTypes"; +import type { AcpDialect, AcpSpawnPlan } from "./acpHostTypes"; + +/** Default ceiling for a single request. A prompt uses its own, much larger. */ +export const ACP_DEFAULT_REQUEST_TIMEOUT_MS = 60_000; +/** Handshake must be quick. A hung binary must not wedge a chat launch. */ +export const ACP_HANDSHAKE_TIMEOUT_MS = 20_000; +/** Grace period between SIGTERM and SIGKILL on disposal. */ +export const ACP_TERMINATE_GRACE_MS = 1_500; + +/** A JSON-RPC error the agent returned. */ +export class AcpRpcError extends Error { + readonly code: number; + readonly data: unknown; + readonly method: string; + + constructor(method: string, payload: AcpRpcErrorPayload) { + super(`ACP ${method} failed (${payload.code}): ${payload.message}`); + this.name = "AcpRpcError"; + this.code = payload.code; + this.data = payload.data; + this.method = method; + } + + /** True when the agent does not implement the method at all. */ + get isMethodNotFound(): boolean { + return this.code === ACP_RPC_METHOD_NOT_FOUND; + } +} + +/** The connection went away before the request settled. */ +export class AcpConnectionClosedError extends Error { + constructor(reason: string) { + super(`ACP connection closed: ${reason}`); + this.name = "AcpConnectionClosedError"; + } +} + +/** The request did not settle inside its deadline. */ +export class AcpRequestTimeoutError extends Error { + readonly method: string; + + constructor(method: string, timeoutMs: number) { + super(`ACP ${method} did not answer within ${timeoutMs}ms.`); + this.name = "AcpRequestTimeoutError"; + this.method = method; + } +} + +export type AcpReverseRequestHandler = (params: unknown) => Promise; +export type AcpReverseRequestMatcher = (params: unknown) => boolean; + +export type AcpConnectionExit = { + code: number | null; + signal: NodeJS.Signals | null; + /** Text the agent last wrote to stderr. Explains most startup failures. */ + stderrTail: string; +}; + +export type AcpConnection = { + readonly pid: number | null; + readonly spawnPlan: AcpSpawnPlan; + /** + * Filled by `initializeAcpConnection`. Null before the handshake completes. + * Only that function writes it; everything else reads it for diagnostics. + */ + initializeResult: AcpInitializeResponse | null; + isAlive(): boolean; + request(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise; + notify(method: string, params?: unknown): void; + /** Register a handler for `session/update`. Returns an unsubscribe function. */ + onSessionUpdate(handler: (notification: AcpSessionNotification) => void): () => void; + /** Register a handler for any notification method. Returns unsubscribe. */ + onNotification(method: string, handler: (params: unknown) => void): () => void; + /** + * Answer an agent-to-client request. A matcher scopes a handler to one + * protocol object (for example, one session inside a pooled process). + * Returns unsubscribe. + */ + onRequest( + method: string, + handler: AcpReverseRequestHandler, + options?: { matches?: AcpReverseRequestMatcher }, + ): () => void; + onExit(handler: (exit: AcpConnectionExit) => void): () => void; + /** Resolves when the process has actually gone. */ + waitForExit(): Promise; + dispose(reason: string): void; +}; + +type PendingRequest = { + method: string; + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout | null; +}; + +type ReverseRequestRegistration = { + handler: AcpReverseRequestHandler; + matches?: AcpReverseRequestMatcher; +}; + +const STDERR_TAIL_LIMIT = 4_000; + +/** Split a byte stream into complete lines. Holds the partial tail. */ +function createLineSplitter(onLine: (line: string) => void): (chunk: Buffer | string) => void { + let buffer = ""; + return (chunk) => { + buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + let index = buffer.indexOf("\n"); + while (index !== -1) { + const line = buffer.slice(0, index).replace(/\r$/, ""); + buffer = buffer.slice(index + 1); + if (line.length) onLine(line); + index = buffer.indexOf("\n"); + } + }; +} + +function buildClientCapabilities(dialect: AcpDialect): AcpClientCapabilities { + const capabilities: AcpClientCapabilities = {}; + // Honest by default. ADE does not serve the agent's file reads, so it must + // not claim it can. An agent that believes the client owns the file system + // will route binary reads through a text channel and corrupt them. + if (dialect.advertiseFsCapability) { + capabilities.fs = { readTextFile: true, writeTextFile: true }; + } + if (dialect.advertiseTerminalCapability) capabilities.terminal = true; + return capabilities; +} + +export type CreateAcpConnectionArgs = { + dialect: AcpDialect; + spawnPlan: AcpSpawnPlan; + logger?: Logger; + /** Test seam. Replaces the real spawn with a scripted process. */ + spawnOverride?: (plan: AcpSpawnPlan) => ChildProcessWithoutNullStreams; +}; + +/** + * Start the agent process and open the JSON-RPC channel. + * + * The returned connection is live but not yet initialized. Call + * `initializeAcpConnection` next. + */ +export function createAcpConnection(args: CreateAcpConnectionArgs): AcpConnection { + const { dialect, spawnPlan, logger } = args; + const invocation = resolveCliSpawnInvocation(spawnPlan.command, spawnPlan.args); + const child = args.spawnOverride + ? args.spawnOverride(spawnPlan) + : (spawn(invocation.command, invocation.args, { + cwd: spawnPlan.cwd, + env: spawnPlan.env, + stdio: ["pipe", "pipe", "pipe"], + // A process group on POSIX so the whole tree takes the signal. Windows + // uses `taskkill /T /F` inside terminateChildProcessTree instead. + detached: process.platform !== "win32", + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }) as ChildProcessWithoutNullStreams); + + const pending = new Map(); + const sessionUpdateHandlers = new Set<(notification: AcpSessionNotification) => void>(); + const notificationHandlers = new Map void>>(); + const requestHandlers = new Map>(); + const exitHandlers = new Set<(exit: AcpConnectionExit) => void>(); + + let nextRequestId = 1; + let disposed = false; + let exited: AcpConnectionExit | null = null; + let killTimer: NodeJS.Timeout | null = null; + let stderrTail = ""; + let initializeResult: AcpInitializeResponse | null = null; + const exitWaiters = new Set<(exit: AcpConnectionExit) => void>(); + + const logEvent = (level: "debug" | "info" | "warn" | "error", event: string, meta?: Record) => { + logger?.[level](event, { provider: dialect.providerId, pid: child.pid ?? null, ...meta }); + }; + + const writeFrame = (frame: Record): boolean => { + if (exited || !child.stdin || child.stdin.destroyed) return false; + try { + child.stdin.write(`${JSON.stringify(frame)}\n`); + return true; + } catch (error) { + logEvent("warn", "agent_chat.acp_write_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + }; + + const settleExit = (exit: AcpConnectionExit) => { + if (exited) return; + exited = exit; + if (killTimer) { + clearTimeout(killTimer); + killTimer = null; + } + const closedError = new AcpConnectionClosedError( + `${dialect.providerId} exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`, + ); + for (const [, waiter] of pending) { + if (waiter.timer) clearTimeout(waiter.timer); + waiter.reject(closedError); + } + pending.clear(); + for (const handler of exitHandlers) { + try { + handler(exit); + } catch (error) { + logEvent("warn", "agent_chat.acp_exit_handler_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + for (const waiter of exitWaiters) waiter(exit); + exitWaiters.clear(); + }; + + const answerReverseRequest = (id: AcpRpcId, method: string, params: unknown) => { + const registrations = requestHandlers.get(method); + const registration = [...(registrations ?? [])].find((candidate) => { + if (!candidate.matches) return true; + try { + return candidate.matches(params); + } catch (error) { + logEvent("warn", "agent_chat.acp_request_matcher_failed", { + method, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + }); + if (!registration) { + // An unhandled agent-to-client request must be answered, or the agent + // waits forever. Method-not-found is the honest answer, and it is what a + // capability the client never advertised deserves. + writeFrame({ + jsonrpc: "2.0", + id, + error: { code: ACP_RPC_METHOD_NOT_FOUND, message: `ADE does not implement ${method}.` }, + }); + return; + } + void (async () => { + try { + const result = await registration.handler(params); + writeFrame({ jsonrpc: "2.0", id, result: result ?? {} }); + } catch (error) { + writeFrame({ + jsonrpc: "2.0", + id, + error: { + code: -32000, + message: error instanceof Error ? error.message : String(error), + }, + }); + } + })(); + }; + + const handleFrame = (frame: Record) => { + const id = normalizeAcpRpcId(frame.id); + const method = typeof frame.method === "string" ? frame.method : null; + + if (method && id !== undefined && id !== null) { + answerReverseRequest(id, method, frame.params); + return; + } + + if (method) { + if (method === ACP_METHOD.sessionUpdate) { + const notification = normalizeAcpSessionNotification(frame.params); + if (notification) { + for (const handler of sessionUpdateHandlers) { + try { + handler(notification); + } catch (error) { + logEvent("warn", "agent_chat.acp_session_update_handler_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + return; + } + const handlers = notificationHandlers.get(method); + if (!handlers?.size) { + logEvent("debug", "agent_chat.acp_unhandled_notification", { method }); + return; + } + for (const handler of handlers) { + try { + handler(frame.params); + } catch (error) { + logEvent("warn", "agent_chat.acp_notification_handler_failed", { + method, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return; + } + + if (id === undefined || id === null) { + logEvent("debug", "agent_chat.acp_frame_without_id", {}); + return; + } + const waiter = pending.get(id); + if (!waiter) { + logEvent("debug", "agent_chat.acp_response_without_request", { id: String(id) }); + return; + } + pending.delete(id); + if (waiter.timer) clearTimeout(waiter.timer); + const errorPayload = normalizeAcpRpcError(frame.error); + if (errorPayload) { + waiter.reject(new AcpRpcError(waiter.method, errorPayload)); + return; + } + waiter.resolve(frame.result); + }; + + const readStdoutLine = createLineSplitter((line) => { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + // Some CLIs print a banner or an update notice before the first frame. + // That text is not a protocol error, so it must not kill the connection. + logEvent("debug", "agent_chat.acp_stdout_non_protocol", { text: trimmed.slice(0, 400) }); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + logEvent("warn", "agent_chat.acp_stdout_unparsable", { text: trimmed.slice(0, 400) }); + return; + } + const frame = normalizeAcpRpcFrame(parsed); + if (!frame) return; + handleFrame(frame); + }); + + child.stdout?.on("data", readStdoutLine); + child.stderr?.on("data", (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + stderrTail = `${stderrTail}${text}`.slice(-STDERR_TAIL_LIMIT); + const trimmed = text.trim(); + if (trimmed.length) logEvent("warn", "agent_chat.acp_stderr", { text: trimmed.slice(0, 1_000) }); + }); + child.on("error", (error) => { + logEvent("error", "agent_chat.acp_process_error", { error: error.message }); + settleExit({ code: null, signal: null, stderrTail: `${stderrTail}\n${error.message}`.trim() }); + }); + child.on("exit", (code, signal) => { + logEvent("info", "agent_chat.acp_process_exit", { code, signal }); + settleExit({ code, signal, stderrTail }); + }); + + const connection: AcpConnection = { + get pid() { + return child.pid ?? null; + }, + spawnPlan, + get initializeResult() { + return initializeResult; + }, + set initializeResult(value: AcpInitializeResponse | null) { + initializeResult = value; + }, + isAlive: () => !disposed && !exited, + request: (method: string, params?: unknown, options?: { timeoutMs?: number }) => { + return new Promise((resolve, reject) => { + if (exited) { + reject(new AcpConnectionClosedError(`${dialect.providerId} is not running`)); + return; + } + const id = nextRequestId++; + const timeoutMs = options?.timeoutMs ?? ACP_DEFAULT_REQUEST_TIMEOUT_MS; + const timer = + timeoutMs > 0 + ? setTimeout(() => { + if (!pending.delete(id)) return; + reject(new AcpRequestTimeoutError(method, timeoutMs)); + }, timeoutMs) + : null; + timer?.unref?.(); + pending.set(id, { + method, + resolve: (value) => resolve(value as TResult), + reject, + timer, + }); + const sent = writeFrame({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) }); + if (!sent && pending.delete(id)) { + if (timer) clearTimeout(timer); + reject(new AcpConnectionClosedError(`${dialect.providerId} stdin is not writable`)); + } + }); + }, + notify: (method, params) => { + writeFrame({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) }); + }, + onSessionUpdate: (handler) => { + sessionUpdateHandlers.add(handler); + return () => sessionUpdateHandlers.delete(handler); + }, + onNotification: (method, handler) => { + const handlers = notificationHandlers.get(method) ?? new Set(); + handlers.add(handler); + notificationHandlers.set(method, handlers); + return () => { + handlers.delete(handler); + if (!handlers.size) notificationHandlers.delete(method); + }; + }, + onRequest: (method, handler, options) => { + const registration: ReverseRequestRegistration = { + handler, + ...(options?.matches ? { matches: options.matches } : {}), + }; + const registrations = requestHandlers.get(method) ?? new Set(); + registrations.add(registration); + requestHandlers.set(method, registrations); + return () => { + const current = requestHandlers.get(method); + if (!current) return; + current.delete(registration); + if (!current.size) requestHandlers.delete(method); + }; + }, + onExit: (handler) => { + if (exited) { + handler(exited); + return () => undefined; + } + exitHandlers.add(handler); + return () => exitHandlers.delete(handler); + }, + waitForExit: () => + new Promise((resolve) => { + if (exited) { + resolve(exited); + return; + } + exitWaiters.add(resolve); + }), + dispose: (reason) => { + if (disposed) return; + disposed = true; + logEvent("info", "agent_chat.acp_dispose", { reason }); + for (const [, waiter] of pending) { + if (waiter.timer) clearTimeout(waiter.timer); + waiter.reject(new AcpConnectionClosedError(reason)); + } + pending.clear(); + try { + child.stdin?.end(); + } catch { + // The pipe may already be gone. The kill below still runs. + } + if (!exited) { + killTimer = terminateChildProcessTree(child, null, ACP_TERMINATE_GRACE_MS); + killTimer.unref?.(); + } + }, + }; + + return connection; +} + +export type InitializeAcpConnectionResult = { + response: AcpInitializeResponse; + /** True when the agent answered with a protocol version this host speaks. */ + protocolVersionAccepted: boolean; +}; + +/** + * Run the `initialize` handshake. + * + * The client capabilities come from the dialect, and they are honest: ADE + * claims only what it actually serves. + */ +export async function initializeAcpConnection(args: { + connection: AcpConnection; + dialect: AcpDialect; + timeoutMs?: number; +}): Promise { + const { connection, dialect } = args; + const response = await connection.request( + ACP_METHOD.initialize, + { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: buildClientCapabilities(dialect), + clientInfo: dialect.clientInfo, + ...(dialect.initializeMeta ? { _meta: { ...dialect.initializeMeta } } : {}), + }, + { timeoutMs: args.timeoutMs ?? ACP_HANDSHAKE_TIMEOUT_MS }, + ); + connection.initializeResult = response; + return { + response, + protocolVersionAccepted: response.protocolVersion <= ACP_PROTOCOL_VERSION, + }; +} diff --git a/apps/desktop/src/main/services/chat/acpHost/acpDialects/copilot.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/copilot.ts new file mode 100644 index 0000000000..147bdf09d1 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/copilot.ts @@ -0,0 +1,192 @@ +/** + * GitHub Copilot CLI dialect. `copilot --acp`, npm package `@github/copilot`. + * + * GitHub ships this ACP server as a preview, and ADE labels it preview in + * Settings until GitHub fixes the cancel report and drops the preview label. + * + * ## Verified rules + * + * - Version 1.0.82 (ACP agent 1.0.4) advertises `loadSession`, image prompts, + * and session list. It does **not** advertise `session/resume` or + * `session/close`; both answer -32601. ADE still sends `session/close` and + * degrades, keeping the pooled process. Live 1.0.82 on this machine completed + * real `session/prompt` turns (text `"ping"`, usage on the prompt result and + * `usage_update`). Cancel mid-prompt returned `stopReason: "end_turn"` with + * partial text — github/copilot-cli #4561, live. Config options arrive as + * `currentValue` / nested `value`, not ADE's `value` / `options[].id`. + * - **Known bug.** Cancel may report `stopReason: "end_turn"` + * (github/copilot-cli issue 4561). ADE records its own cancel and marks the + * turn interrupted whatever the agent says. That accounting lives in the + * host, and it applies to every dialect. + * - Slash commands arrive as ordinary prompts plus an + * `available_commands_update`. Some of the advertised commands only work in + * Copilot's own terminal UI. If a user picks one of those in ADE, the text + * reaches the model as a prompt. They are filtered out of the picker. + * - The server-start flags `--effort`, `--available-tools`, and + * `--excluded-tools` are process global. `session/new` cannot override them, + * so a chat that needs a different value needs a different process. Those + * values are therefore part of the pool key. + * - **ADE never writes Copilot's config.** There was once a trust pre-seed + * here that added the lane worktree to `$COPILOT_HOME/config.json`. It is + * removed. A three-arm live experiment on 1.0.82 showed headless + * `session/new` does not deadlock without any trust key and without + * `--add-dir`, in a throwaway git cwd and in a nested independent git repo: + * writes completed with `allow_all: "off"` and **zero** + * `session/request_permission` in every arm. The write bought nothing, and + * it cost something real — `config.json` is JSONC (leading `//` comments), + * `JSON.parse` throws on that header, and the recover path rewrote a user's + * live file as a stub, after which every `session/prompt` answered "No model + * available". For the record, since the wrong key name has been repeated in + * research notes: live 1.0.82 persists `trustedFolders` (camelCase), not + * `trusted_folders` (snake_case). Do not add either back. + * - `--add-dir` on the spawn plan is the session path gate. It is argv, not a + * rewrite of user state, so it stays. + * - `COPILOT_HOME` names the config directory, and `--config-dir` is the flag + * form. Sessions live at `/session-state//`. + */ + +import { + capability, + capabilityAbsent, + defineAcpDialect, + type AcpSpawnContext, + type AcpSpawnPlan, +} from "../acpHostTypes"; +import type { AcpAvailableCommand } from "../acpProtocolTypes"; +import { + ADE_CLIENT_INFO, + inlineImagePrompt, + standardClose, + standardLoad, + transportGatedMcpInjection, + withOptionalEnv, +} from "./shared"; + +/** + * Commands the Copilot terminal UI owns. ADE hides them from its picker. + * + * The first four are the ones the research verified. The rest are terminal-only + * by the same reasoning: they act on the terminal session, not on the model, so + * sending them as a prompt would waste a turn. + */ +export const COPILOT_TUI_ONLY_COMMANDS: ReadonlySet = new Set([ + "diff", + "resume", + "login", + "undo", + "logout", + "exit", + "quit", + "clear", + "theme", + "help", + "cwd", + "reset", +]); + +export const COPILOT_CANCEL_DEGRADATION_NOTE = + "Copilot sometimes reports a stopped turn as finished. ADE marks it stopped."; + +function normalizeCommandName(name: string): string { + return name.replace(/^\/+/, "").trim().toLowerCase(); +} + +export function includeCopilotSlashCommand(command: AcpAvailableCommand): boolean { + return !COPILOT_TUI_ONLY_COMMANDS.has(normalizeCommandName(command.name)); +} + +function buildSpawnPlan(context: AcpSpawnContext): AcpSpawnPlan { + const args = ["--acp"]; + if (context.configHome?.length) args.push("--config-dir", context.configHome); + // `--add-dir` is the session path gate. It is argv only — it does not + // rewrite config.json, which is why it survived the removal of the trust + // pre-seed. Live 1.0.82 ACP writes did not emit + // `session/request_permission` with or without it, so it is a cheap gate, + // not a supervision mechanism. + args.push("--add-dir", context.cwd); + if (context.reasoningEffort?.length) args.push("--effort", context.reasoningEffort); + return { + command: context.binaryPath, + args, + cwd: context.cwd, + env: withOptionalEnv(context.baseEnv, { COPILOT_HOME: context.configHome }), + }; +} + +export const copilotDialect = defineAcpDialect({ + providerId: "copilot", + displayName: "GitHub Copilot", + tier: "preview", + binaryNames: ["copilot"], + buildSpawnPlan, + + // Copilot 1.0.82 answers a `session/cancel` REQUEST with -32601. The + // notification form is the one the binary accepts, same as Grok. + cancelStyle: "notification", + // `--effort` is process global, so two chats with different effort values + // must not share a process. The environment carries the config home; the + // effort flag is folded into the pool key by the caller through the spawn + // plan arguments hash. + poolEnvKeys: ["COPILOT_HOME", "GITHUB_TOKEN", "GH_TOKEN"], + oneProcessPerSession: false, + advertiseFsCapability: false, + advertiseTerminalCapability: false, + initializeMeta: null, + clientInfo: ADE_CLIENT_INFO, + postSessionNewNotifications: () => [], + includeSlashCommand: includeCopilotSlashCommand, + + ignoredNotificationMethods: [], + + sessionIdPersistence: { + assignableAtLaunch: true, + sessionsDirName: "session-state", + idShape: "uuid", + }, + + authProbe: { + methodId: null, + loginCommand: "copilot login", + apiKeyEnvVars: ["GITHUB_TOKEN", "GH_TOKEN"], + }, + + degradationNotes: [COPILOT_CANCEL_DEGRADATION_NOTE], + + usageSource: "usage_update", + usage: capability(({ usageUpdate, promptUsage }) => { + if (usageUpdate) { + return { + contextUsedTokens: usageUpdate.used, + contextWindowTokens: usageUpdate.size, + ...(usageUpdate.cost && usageUpdate.cost.currency.toUpperCase() === "USD" + ? { costUsd: usageUpdate.cost.amount } + : {}), + }; + } + if (promptUsage) { + return { + ...(promptUsage.inputTokens !== undefined ? { inputTokens: promptUsage.inputTokens } : {}), + ...(promptUsage.outputTokens !== undefined ? { outputTokens: promptUsage.outputTokens } : {}), + ...(promptUsage.totalTokens !== undefined ? { totalTokens: promptUsage.totalTokens } : {}), + ...(promptUsage.cachedReadTokens != null ? { cacheReadTokens: promptUsage.cachedReadTokens } : {}), + ...(promptUsage.cachedWriteTokens != null ? { cacheWriteTokens: promptUsage.cachedWriteTokens } : {}), + ...(promptUsage.thoughtTokens != null ? { reasoningTokens: promptUsage.thoughtTokens } : {}), + }; + } + return null; + }), + + closeStyle: "close_request", + closeSession: capability(standardClose), + + // `session/load` is verified. `session/resume` is not, so ADE does not claim + // it. W5 can promote this to `resume_preferred` after a live probe. + loadPolicy: "load_only", + resumeSession: capabilityAbsent, + loadSession: capability(standardLoad), + + sessionConfig: capabilityAbsent, + mcpInjection: capability(transportGatedMcpInjection), + imagePrompts: capability(inlineImagePrompt), + configOptionIds: [], +}); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpDialects/grok.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/grok.ts new file mode 100644 index 0000000000..2cb3ba5721 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/grok.ts @@ -0,0 +1,302 @@ +/** + * Grok dialect. `grok agent stdio`, npm package `@xai-official/grok`. + * + * There is no `grok acp`. The ACP server is a subcommand of `agent`. + * + * ## Spawn flags, and why each one is there + * + * `_GROK_CLAUDE_MARKER_OVERRIDE=1 grok --no-auto-update --no-plan + * --permission-mode agent --no-leader stdio` + * + * - `--no-auto-update`: auto update replaces the binary while the host holds + * an open connection to it. + * - `--no-plan`: the native plan mode hangs an external host. ADE owns plan UX. + * - `--no-leader`: leader mode lets one session contaminate another. + * - `--permission-mode` + `_GROK_CLAUDE_MARKER_OVERRIDE`: the two halves of + * the approval neutralization. See rule 1. + * + * The flag position matters. `--no-auto-update` and `--no-plan` are global, so + * they come before `agent`. `--no-leader` is agent scoped, so it sits between + * `agent` and `stdio`. + * + * ## Verified rules + * + * 1. Grok merges permission RULES from several sources and evaluates MODE + * flags AFTER those rules, so no flag alone can force ask-always. The + * source that matters is the USER's `~/.claude/settings.json` + * `permissions.defaultMode` — that value, not the handful of allow rules + * beside it, is what seeds Grok's auto-classifier and silently approves + * writes. `GROK_HOME` does not scope that read. `_meta.autoMode: false` at + * `session/new` does NOT switch it off, `startupHints` does not, and + * `x.ai/yolo_mode_changed` is method-not-found on 1.0.13. The working kill + * switch is `_GROK_CLAUDE_MARKER_OVERRIDE=1` in the child environment, and + * it only works together with `--permission-mode`. Both halves and the risk + * they carry are documented in `shared/grokSupervision.ts`. + * 2. `_meta.clientIdentifier: "ade"` must be stamped at `initialize`. + * 3. `x.ai/session_notification` with `pending_interaction{kind:"permission"}` + * is a spinner hint, not a permission request. Never answer it. + * 4. Read, Grep, and WebSearch never prompt. They are safe commands. Silence + * for reads is correct behavior, not a missing prompt. + * 5. The option ids Grok actually offers are `allow-edits-session`, + * `allow-once`, and `reject-once`. The permission bridge classifies them + * from the id, so an unrecognized id still lands on a safe kind. + * 6. `session/cancel` as a REQUEST answers -32601. Send it as a notification. + * 7. Usage does not arrive as `usage_update`. It rides the `session/prompt` + * result `_meta`. + * 8. Never advertise the client `fs` capability. Grok proxies binary reads + * through the text file system and corrupts the bytes. + * 9. `GROK_HOME` IS a valid config-home override (`xai-dirs` reads it). ADE + * still sets nothing, because a private home would hide the user's own + * `grok login` credential and rules. Reusing `~/.grok` is a choice, not a + * limitation. + */ + +import { + capability, + capabilityAbsent, + defineAcpDialect, + type AcpSpawnContext, + type AcpSpawnPlan, + type AcpUsageSample, +} from "../acpHostTypes"; +import { + ADE_CLIENT_INFO, + standardClose, + standardLoad, + standardResume, + transportGatedMcpInjection, +} from "./shared"; +import { grokSupervisionEnv } from "../../../../../shared/grokSupervision"; + +export { GROK_CLAUDE_MARKER_OVERRIDE_ENV, grokSupervisionEnv } from "../../../../../shared/grokSupervision"; + +/** Extension notification that switches Grok's auto-approve mode off. */ +export const GROK_YOLO_MODE_CHANGED_METHOD = "x.ai/yolo_mode_changed"; + +/** Extension notification that is only a spinner hint. Never answer it. */ +export const GROK_SESSION_NOTIFICATION_METHOD = "x.ai/session_notification"; + +/** Lowest Grok version this dialect is written against. */ +export const GROK_MINIMUM_VERSION = "1.0.13"; + +/** + * xAI `costUsdTicks` are nano-dollars: 1_000_000_000 ticks = $1.00. + * A 1e6 scale showed a 30k-token ping as $86.65 in the usage footer. + */ +const GROK_COST_TICKS_PER_USD = 1_000_000_000; + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readFromLayers( + layers: Array | null | undefined>, + key: string, +): number | undefined { + for (const layer of layers) { + const value = readNumber(layer?.[key]); + if (value !== undefined) return value; + } + return undefined; +} + +function readModelUsage(modelUsage: unknown): Pick | null { + const record = asRecord(modelUsage); + if (!record) return null; + let input = 0; + let output = 0; + let reasoning = 0; + let sawAny = false; + for (const entry of Object.values(record)) { + const row = asRecord(entry); + if (!row) continue; + const entryInput = readNumber(row.inputTokens) ?? readNumber(row.promptTokens); + const entryOutput = readNumber(row.outputTokens) ?? readNumber(row.completionTokens); + const entryReasoning = readNumber(row.reasoningTokens) ?? readNumber(row.thoughtTokens); + if (entryInput !== undefined) { + input += entryInput; + sawAny = true; + } + if (entryOutput !== undefined) { + output += entryOutput; + sawAny = true; + } + if (entryReasoning !== undefined) { + reasoning += entryReasoning; + sawAny = true; + } + } + if (!sawAny) return null; + return { + inputTokens: input, + outputTokens: output, + ...(reasoning ? { reasoningTokens: reasoning } : {}), + totalTokens: input + output, + }; +} + +/** + * Read Grok's usage from the `session/prompt` result `_meta`. + * + * Grok 1.0.13 puts `costUsdTicks` and `modelUsage` under `_meta.usage`, and + * repeats the token totals at the top level. Older captures put `costUsdTicks` + * and `modelUsage` at the top level. The reader accepts both. + */ +export function readGrokPromptUsage(meta: Record | null | undefined): AcpUsageSample | null { + if (!meta) return null; + const nested = asRecord(meta.usage); + const sample: AcpUsageSample = {}; + + const costTicks = readFromLayers([meta, nested], "costUsdTicks"); + if (costTicks !== undefined) sample.costUsd = costTicks / GROK_COST_TICKS_PER_USD; + + const cachedRead = readFromLayers([meta, nested], "cachedReadTokens"); + if (cachedRead !== undefined) sample.cacheReadTokens = cachedRead; + + const fromModels = readModelUsage(meta.modelUsage) ?? readModelUsage(nested?.modelUsage); + if (fromModels) { + Object.assign(sample, fromModels); + } else { + const input = readFromLayers([meta, nested], "inputTokens"); + const output = readFromLayers([meta, nested], "outputTokens"); + const reasoning = readFromLayers([meta, nested], "reasoningTokens"); + const total = readFromLayers([meta, nested], "totalTokens"); + if (input !== undefined) sample.inputTokens = input; + if (output !== undefined) sample.outputTokens = output; + if (reasoning !== undefined) sample.reasoningTokens = reasoning; + if (total !== undefined) sample.totalTokens = total; + else if (input !== undefined || output !== undefined) { + sample.totalTokens = (input ?? 0) + (output ?? 0); + } + } + + return Object.keys(sample).length ? sample : null; +} + +/** + * Grok's `--permission-mode` is a process-global spawn flag. + * + * It is one of the two halves of the neutralization: it overrides the user's + * `~/.grok/config.toml [ui] permission_mode`. On its own it is not enough, + * because Grok evaluates mode flags AFTER the rules it merged from the user's + * Claude settings — which is why `grokSupervisionEnv` rides alongside it. + * `--no-plan` already disables Grok's native plan mode, so ADE's plan posture + * maps onto `default` rather than Grok's hanging `plan` value. + */ +export function grokPermissionModeFlags(permissionMode: string | null | undefined): string[] { + switch (permissionMode) { + case "yolo": + return ["--permission-mode", "bypassPermissions"]; + case "auto": + return ["--permission-mode", "auto"]; + case "auto-edit": + return ["--permission-mode", "acceptEdits"]; + default: + return ["--permission-mode", "default"]; + } +} + +function buildSpawnPlan(context: AcpSpawnContext): AcpSpawnPlan { + const args = [ + "--no-auto-update", + "--no-plan", + ...grokPermissionModeFlags(context.permissionMode), + "agent", + "--no-leader", + ]; + // Model and effort go on the command line. Grok's `session/set_config_option` + // is non-standard: it keys on `configId` and its value enumeration is not + // documented, so ADE does not use it. + if (context.modelId?.length) args.push("-m", context.modelId); + if (context.reasoningEffort?.length) args.push("--reasoning-effort", context.reasoningEffort); + args.push("stdio"); + return { + command: context.binaryPath, + args, + cwd: context.cwd, + // `GROK_HOME` is a real override, but ADE deliberately does not set one: + // the user's `~/.grok` holds the login token and their own rules. The only + // provider environment ADE adds is the Claude-import kill switch, which + // must travel with the `--permission-mode` flag above or neither works. + env: { ...context.baseEnv, ...grokSupervisionEnv() }, + }; +} + +export const grokDialect = defineAcpDialect({ + providerId: "grok", + displayName: "Grok", + tier: "preview", + binaryNames: ["grok"], + buildSpawnPlan, + + // A `session/cancel` REQUEST answers -32601. The notification form works. + cancelStyle: "notification", + poolEnvKeys: ["XAI_API_KEY"], + oneProcessPerSession: false, + // Grok corrupts binary assets when it proxies reads through the client text + // file system. This must stay false. + advertiseFsCapability: false, + advertiseTerminalCapability: false, + initializeMeta: { clientIdentifier: "ade" }, + clientInfo: ADE_CLIENT_INFO, + + postSessionNewNotifications: () => [ + // Kept as a best-effort extra. Grok 1.0.13 answers this with + // "Method not found"; the spawn `--permission-mode` flag plus + // `_GROK_CLAUDE_MARKER_OVERRIDE=1` are what actually defeat the + // Claude-settings leak. Older builds may still honor this. + { + method: GROK_YOLO_MODE_CHANGED_METHOD, + params: { auto_mode: false, permission_mode: "ask" }, + }, + ], + + // Grok re-sends its command list repeatedly. The translator dedupes; nothing + // needs filtering here. + includeSlashCommand: () => true, + + // A spinner hint, not a permission request. Receive it and do nothing. + ignoredNotificationMethods: [GROK_SESSION_NOTIFICATION_METHOD], + + sessionIdPersistence: { + assignableAtLaunch: true, + sessionsDirName: null, + idShape: "uuid", + }, + + authProbe: { + methodId: null, + loginCommand: "grok login", + // A stored session token outranks the environment key. + apiKeyEnvVars: ["XAI_API_KEY"], + }, + + degradationNotes: [ + "Grok does not accept image or audio attachments.", + ], + + usageSource: "prompt_result_meta", + usage: capability(({ promptResponse }) => + readGrokPromptUsage((promptResponse?._meta ?? null) as Record | null), + ), + + closeStyle: "close_request", + closeSession: capability(standardClose), + + loadPolicy: "resume_preferred", + resumeSession: capability(standardResume), + loadSession: capability(standardLoad), + + // Non-standard on this agent. Model and effort ride spawn flags instead. + sessionConfig: capabilityAbsent, + mcpInjection: capability(transportGatedMcpInjection), + // No image or audio prompt support. + imagePrompts: capabilityAbsent, + configOptionIds: [], +}); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpDialects/index.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/index.ts new file mode 100644 index 0000000000..696d0c815f --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/index.ts @@ -0,0 +1,38 @@ +/** + * The dialect registry. + * + * The table is an exhaustive `Record`. Adding a member to `AcpProviderId` + * without adding its dialect is a compile error, so no provider can reach the + * host through a silent default branch. + */ + +import type { AcpDialect, AcpProviderId } from "../acpHostTypes"; +import { copilotDialect } from "./copilot"; +import { grokDialect } from "./grok"; +import { kimiDialect } from "./kimi"; +import { qwenDialect } from "./qwen"; + +export const ACP_DIALECTS: Record = { + qwen: qwenDialect, + kimi: kimiDialect, + grok: grokDialect, + copilot: copilotDialect, +}; + +export function acpDialectFor(providerId: AcpProviderId): AcpDialect { + return ACP_DIALECTS[providerId]; +} + +export { copilotDialect, grokDialect, kimiDialect, qwenDialect }; +export { COPILOT_TUI_ONLY_COMMANDS, includeCopilotSlashCommand } from "./copilot"; +export { + GROK_CLAUDE_MARKER_OVERRIDE_ENV, + GROK_MINIMUM_VERSION, + GROK_SESSION_NOTIFICATION_METHOD, + GROK_YOLO_MODE_CHANGED_METHOD, + grokPermissionModeFlags, + grokSupervisionEnv, + readGrokPromptUsage, +} from "./grok"; +export { KIMI_USAGE_DEGRADATION_NOTE, KIMI_WINDOWS_DEGRADATION_NOTE } from "./kimi"; +export { QWEN_CONFIG_OPTION_IDS } from "./qwen"; diff --git a/apps/desktop/src/main/services/chat/acpHost/acpDialects/kimi.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/kimi.ts new file mode 100644 index 0000000000..e2934a6ee7 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/kimi.ts @@ -0,0 +1,115 @@ +/** + * Kimi dialect. `kimi acp`, native binary from the MoonshotAI/kimi-code repo. + * + * This is NOT the deprecated Python `kimi-cli`. + * + * Live 0.39.1 handshake: `loadSession`, list, resume, **and `session/close`** + * (also delete/fork/additionalDirectories). A dummy `session/close` returns + * `{}`. The 0.31.x "no close → one process per session" hole is gone on this + * version, so ADE pools like Qwen used to. **No usage on the wire** is still + * assumed until a live authenticated turn proves otherwise; the meter stays + * hidden. Image prompts yes. Audio no. `agentCapabilities.auth.logout` is + * advertised; ADE has no ACP logout action yet. + * + * Kimi cannot take a session id at launch. The host reads the id the agent + * reports at `session/new`, and W4 stores it. The ids are ULID shaped. + * + * `KIMI_CODE_HOME` names the config directory itself, and it defaults to + * `~/.kimi-code`. The config file inside is `config.toml`. ADE does not write + * that file — same rule as Copilot's config.json. + * + * Auth: `authenticate` method id `login`, type `terminal` (`kimi login` / + * `kimi acp --login`). Region is `--region global` (kimi.ai) or + * `mainland-cn` (kimi.com). Unauthenticated `session/new` is `-32000 + * Authentication required`. + * + * On Windows the native binary needs Git for Windows, because Git Bash is its + * shell. W4 runs that preflight check and reports a clear error. + */ + +import { + capability, + capabilityAbsent, + defineAcpDialect, + type AcpSpawnContext, + type AcpSpawnPlan, +} from "../acpHostTypes"; +import { + ADE_CLIENT_INFO, + inlineImagePrompt, + standardClose, + standardLoad, + standardResume, + transportGatedMcpInjection, + withOptionalEnv, +} from "./shared"; + +export const KIMI_USAGE_DEGRADATION_NOTE = + "Kimi does not report token usage, so the usage meter is hidden for this chat."; + +export const KIMI_WINDOWS_DEGRADATION_NOTE = + "Kimi needs Git for Windows on this machine, because Git Bash is its shell."; + +function buildSpawnPlan(context: AcpSpawnContext): AcpSpawnPlan { + return { + command: context.binaryPath, + args: ["acp"], + cwd: context.cwd, + env: withOptionalEnv(context.baseEnv, { KIMI_CODE_HOME: context.configHome }), + }; +} + +export const kimiDialect = defineAcpDialect({ + providerId: "kimi", + displayName: "Kimi", + tier: "first_class", + binaryNames: ["kimi"], + buildSpawnPlan, + + cancelStyle: "request", + poolEnvKeys: ["KIMI_CODE_HOME", "MOONSHOT_API_KEY"], + // 0.39.1 implements `session/close`. Two chats in the same lane may share. + oneProcessPerSession: false, + advertiseFsCapability: false, + // Kimi has no terminal reverse RPC, so advertising the capability would be a + // claim it never uses. + advertiseTerminalCapability: false, + initializeMeta: null, + clientInfo: ADE_CLIENT_INFO, + postSessionNewNotifications: () => [], + includeSlashCommand: () => true, + + ignoredNotificationMethods: [], + + sessionIdPersistence: { + // The launcher cannot choose the id. The agent mints it. + assignableAtLaunch: false, + sessionsDirName: "sessions", + idShape: "ulid", + }, + + authProbe: { + methodId: "login", + loginCommand: "kimi login", + apiKeyEnvVars: ["MOONSHOT_API_KEY"], + }, + + degradationNotes: [KIMI_USAGE_DEGRADATION_NOTE], + + usageSource: "none", + usage: capabilityAbsent, + + closeStyle: "close_request", + closeSession: capability(standardClose), + + loadPolicy: "resume_preferred", + resumeSession: capability(standardResume), + loadSession: capability(standardLoad), + + // `session/set_config_option` is not part of Kimi's surface. Permission mode + // and model travel on the command line instead. + sessionConfig: capabilityAbsent, + mcpInjection: capability(transportGatedMcpInjection), + imagePrompts: capability(inlineImagePrompt), + configOptionIds: [], +}); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpDialects/qwen.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/qwen.ts new file mode 100644 index 0000000000..6773dc3686 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/qwen.ts @@ -0,0 +1,109 @@ +/** + * Qwen Code dialect. `qwen --acp`, npm package `@qwen-code/qwen-code`. + * + * Live 0.22.3 handshake: `loadSession`, session list/resume, image + audio + * prompts, MCP http/sse, `session/set_config_option` for mode/model/thinking. + * Slash via `available_commands_update`. + * + * It does **not** advertise `session/close`, and a dummy `session/close` is + * -32601. Ending a chat therefore ends the process (one process per session), + * the same posture Kimi 0.31.x used. Copilot 1.0.82 has the same missing-close + * wire and keeps `close_request` + pool by product call; Qwen follows the + * handshake so leaked agent sessions cannot pile up in a pooled process. + * + * `QWEN_HOME` names the config directory, in the same shape as `CODEX_HOME`. + * + * `qwen auth` is removed in 0.22.3. Unauthenticated `session/new` is + * "Authentication required: Use Qwen Code CLI to authenticate first." The + * advertised method is `openai` (`OPENAI_API_KEY`, `--auth-type=openai`, or a + * custom provider already saved in `settings.json`). ADE does not write + * `~/.qwen`; it reuses whatever the Qwen CLI already has. + */ + +import { + capability, + capabilityAbsent, + defineAcpDialect, + type AcpSpawnContext, + type AcpSpawnPlan, +} from "../acpHostTypes"; +import { + ADE_CLIENT_INFO, + inlineImagePrompt, + standardLoad, + standardResume, + standardSetConfigOption, + transportGatedMcpInjection, + withOptionalEnv, +} from "./shared"; + +/** Config option ids Qwen exposes through `session/set_config_option`. */ +export const QWEN_CONFIG_OPTION_IDS = ["mode", "model", "thinking"] as const; + +function buildSpawnPlan(context: AcpSpawnContext): AcpSpawnPlan { + return { + command: context.binaryPath, + args: ["--acp"], + cwd: context.cwd, + env: withOptionalEnv(context.baseEnv, { QWEN_HOME: context.configHome }), + }; +} + +export const qwenDialect = defineAcpDialect({ + providerId: "qwen", + displayName: "Qwen Code", + tier: "first_class", + binaryNames: ["qwen"], + buildSpawnPlan, + + cancelStyle: "request", + poolEnvKeys: ["QWEN_HOME", "QWEN_RUNTIME_DIR", "OPENAI_BASE_URL", "OPENAI_API_KEY"], + // 0.22.3 has no `session/close`. A process may never be shared. + oneProcessPerSession: true, + advertiseFsCapability: false, + advertiseTerminalCapability: false, + initializeMeta: null, + clientInfo: ADE_CLIENT_INFO, + postSessionNewNotifications: () => [], + includeSlashCommand: () => true, + + ignoredNotificationMethods: [], + + sessionIdPersistence: { + assignableAtLaunch: true, + sessionsDirName: null, + idShape: "uuid", + }, + + authProbe: { + methodId: "openai", + loginCommand: "qwen --auth-type=openai", + apiKeyEnvVars: ["OPENAI_API_KEY", "DASHSCOPE_API_KEY"], + }, + + degradationNotes: [], + + usageSource: "usage_update", + usage: capability(({ usageUpdate }) => { + if (!usageUpdate) return null; + return { + contextUsedTokens: usageUpdate.used, + contextWindowTokens: usageUpdate.size, + ...(usageUpdate.cost && usageUpdate.cost.currency.toUpperCase() === "USD" + ? { costUsd: usageUpdate.cost.amount } + : {}), + }; + }), + + closeStyle: "kill_process", + closeSession: capabilityAbsent, + + loadPolicy: "resume_preferred", + resumeSession: capability(standardResume), + loadSession: capability(standardLoad), + + sessionConfig: capability(standardSetConfigOption), + mcpInjection: capability(transportGatedMcpInjection), + imagePrompts: capability(inlineImagePrompt), + configOptionIds: QWEN_CONFIG_OPTION_IDS, +}); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpDialects/shared.ts b/apps/desktop/src/main/services/chat/acpHost/acpDialects/shared.ts new file mode 100644 index 0000000000..f3cd58266e --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpDialects/shared.ts @@ -0,0 +1,94 @@ +/** + * Pieces every ACP dialect reuses. + * + * Keep provider-specific rules out of this file. A value here must be true for + * all four providers, or it belongs in the dialect that needs it. + */ + +import type { + AcpImagePromptBehavior, + AcpLoadBehavior, + AcpMcpInjectionBehavior, + AcpResumeBehavior, + AcpCloseBehavior, + AcpSessionConfigBehavior, +} from "../acpHostTypes"; +import { ACP_METHOD } from "../acpProtocolTypes"; + +/** ADE's identity at `initialize`. */ +export const ADE_CLIENT_INFO = { + name: "ade", + title: "ADE", + version: "1", +} as const; + +/** Standard `session/resume`. */ +export const standardResume: AcpResumeBehavior = ({ sessionId, cwd, mcpServers }) => ({ + method: ACP_METHOD.sessionResume, + params: { sessionId, cwd, mcpServers }, +}); + +/** Standard `session/load`. */ +export const standardLoad: AcpLoadBehavior = ({ sessionId, cwd, mcpServers }) => ({ + method: ACP_METHOD.sessionLoad, + params: { sessionId, cwd, mcpServers }, +}); + +/** Standard `session/close`. */ +export const standardClose: AcpCloseBehavior = ({ sessionId }) => ({ + method: ACP_METHOD.sessionClose, + params: { sessionId }, +}); + +/** Standard `session/set_config_option`. */ +export const standardSetConfigOption: AcpSessionConfigBehavior = ({ sessionId, configId, value }) => ({ + method: ACP_METHOD.sessionSetConfigOption, + params: + typeof value === "boolean" + ? { sessionId, configId, type: "boolean", value } + : { sessionId, configId, value }, +}); + +/** + * Keep only the MCP transports the agent said it supports. + * + * A stdio server needs no capability flag; it is the protocol baseline. HTTP + * and SSE servers are dropped when the agent did not advertise them, because + * an agent that cannot reach the transport will fail the whole session rather + * than skip one server. + */ +export const transportGatedMcpInjection: AcpMcpInjectionBehavior = ({ + servers, + agentSupportsHttp, + agentSupportsSse, +}) => + servers.filter((server) => { + if (server.type === "http") return agentSupportsHttp; + if (server.type === "sse") return agentSupportsSse; + return true; + }); + +/** Inline base64 image prompt block. */ +export const inlineImagePrompt: AcpImagePromptBehavior = ({ base64Data, mimeType, uri }) => ({ + type: "image", + data: base64Data, + mimeType, + ...(uri ? { uri } : {}), +}); + +/** + * Set an environment variable only when the value exists. + * + * An empty string is a real value to most CLIs, and it usually means "no + * config home". Omitting the key lets the provider use its own default. + */ +export function withOptionalEnv( + base: NodeJS.ProcessEnv, + entries: Record, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...base }; + for (const [key, value] of Object.entries(entries)) { + if (typeof value === "string" && value.length) env[key] = value; + } + return env; +} diff --git a/apps/desktop/src/main/services/chat/acpHost/acpEventTranslator.ts b/apps/desktop/src/main/services/chat/acpHost/acpEventTranslator.ts new file mode 100644 index 0000000000..664253ee25 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpEventTranslator.ts @@ -0,0 +1,582 @@ +/** + * Translate ACP `session/update` notifications into `AgentChatEvent` values. + * + * Two rules govern this file. + * + * 1. **Stable row identity.** A text row is keyed by `messageId`, a tool row by + * `toolCallId`. When the agent sends no `messageId`, the translator mints one + * and keeps using it until the agent starts a new message. A row that + * changes identity between chunks renders twice. + * + * 2. **Cap nothing.** Live IPC publishes an uncompacted envelope. Trimming here + * would remove text the user can see in the live stream and never get back. + * Storage compaction is the host application's job, not the translator's. + * + * The translator holds per-session state, so create one instance per ACP + * session and feed every update for that session through it in arrival order. + */ + +import type { AgentChatEvent, AgentChatPlanStep } from "../../../../shared/types"; +import { assertNever, type AcpSlashCommand, type AcpUsageSample } from "./acpHostTypes"; +import { + normalizeAcpConfigOptions, + type AcpAvailableCommand, + type AcpContentBlock, + type AcpPlanEntry, + type AcpSessionConfigOption, + type AcpSessionUpdate, + type AcpToolCallContent, + type AcpToolCallStatus, + type AcpToolKind, +} from "./acpProtocolTypes"; + +/** + * How ADE renders one tool call. The classification is made once, when the tool + * call first appears, and never changes. A tool call that produced a `command` + * row must not later also produce a `tool_call` row for the same work. + */ +export type AcpToolRowKind = "command" | "file_change" | "tool"; + +type TrackedToolCall = { + rowKind: AcpToolRowKind; + toolName: string; + title: string; + kind: AcpToolKind; + status: AcpToolCallStatus; + /** Set once a `tool_call` (or `command`/`file_change`) row was emitted. */ + opened: boolean; + /** Working directory reported for an execute tool, when it reported one. */ + cwd: string; + /** Text collected from tool content, newest wins for terminal output. */ + lastOutput: string; + /** Paths already announced for an edit tool, to keep row ids stable. */ + diffIndexByPath: Map; +}; + +export type AcpTranslatorCallbacks = { + /** Fired when the advertised slash command list actually changes. */ + onSlashCommands?: (commands: AcpSlashCommand[]) => void; + /** Fired when the agent reports session config options or a mode change. */ + onConfigOptions?: (snapshot: { + options: AcpSessionConfigOption[]; + currentModeId: string | null; + }) => void; + /** Fired for `session_info_update`. Carries the agent's own session title. */ + onSessionInfo?: (info: { title: string | null; updatedAt: string | null }) => void; + /** Fired for every usage sample the dialect could read. */ + onUsage?: (sample: AcpUsageSample) => void; +}; + +export type AcpEventTranslatorOptions = { + /** Reads a `usage_update` payload. Absent when the dialect reports no usage. */ + readUsage?: ((update: Extract) => AcpUsageSample | null) | null; + /** Keeps only the slash commands ADE should offer in its picker. */ + includeSlashCommand?: (command: AcpAvailableCommand) => boolean; + callbacks?: AcpTranslatorCallbacks; +}; + +export type AcpEventTranslator = { + /** Current turn id. Every emitted event carries it. */ + turnId: string | null; + beginTurn(turnId: string): void; + endTurn(): void; + /** Translate one update. Returns the events to publish, in order. */ + translate(update: AcpSessionUpdate): AgentChatEvent[]; + /** Row kind chosen for a tool call. Test and diagnostics seam. */ + rowKindFor(toolCallId: string): AcpToolRowKind | null; + /** Forget per-turn state. Session-scoped state (slash dedupe) survives. */ + resetTurnState(): void; +}; + +function textOfContentBlock(block: AcpContentBlock): string { + switch (block.type) { + case "text": + return block.text; + case "image": + return ""; + case "audio": + return ""; + case "resource_link": + return ""; + case "resource": + return typeof block.resource.text === "string" ? block.resource.text : ""; + default: + return assertNever(block, "acp content block"); + } +} + +function planStatusToAde(status: AcpPlanEntry["status"]): AgentChatPlanStep["status"] { + switch (status) { + case "pending": + return "pending"; + case "in_progress": + return "in_progress"; + case "completed": + return "completed"; + default: + return assertNever(status, "acp plan entry status"); + } +} + +function toolStatusToAde(status: AcpToolCallStatus): "running" | "completed" | "failed" { + switch (status) { + case "pending": + case "in_progress": + return "running"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return assertNever(status, "acp tool call status"); + } +} + +function classifyRowKind(kind: AcpToolKind): AcpToolRowKind { + switch (kind) { + case "execute": + return "command"; + case "edit": + case "delete": + case "move": + return "file_change"; + case "read": + case "search": + case "think": + case "fetch": + case "switch_mode": + case "other": + return "tool"; + default: + return assertNever(kind, "acp tool kind"); + } +} + +function readRawInputString(rawInput: unknown, keys: readonly string[]): string { + if (!rawInput || typeof rawInput !== "object" || Array.isArray(rawInput)) return ""; + const record = rawInput as Record; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.length) return value; + } + return ""; +} + +/** + * Build a unified diff for one file from the before and after text. + * + * ACP sends whole texts, not a patch. The renderers accept patch text, so the + * translator produces one hunk that covers the changed region. Identical lines + * at the start and the end are trimmed first, which keeps a one-line edit to a + * one-line hunk instead of a whole-file rewrite. + */ +export function buildUnifiedDiff(path: string, oldText: string, newText: string): string { + if (oldText === newText) return ""; + const oldLines = oldText.length ? oldText.split("\n") : []; + const newLines = newText.length ? newText.split("\n") : []; + + let prefix = 0; + while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) { + prefix += 1; + } + let suffix = 0; + while ( + suffix < oldLines.length - prefix + && suffix < newLines.length - prefix + && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] + ) { + suffix += 1; + } + + const removed = oldLines.slice(prefix, oldLines.length - suffix); + const added = newLines.slice(prefix, newLines.length - suffix); + const header = [`--- a/${path}`, `+++ b/${path}`]; + const hunk = `@@ -${prefix + 1},${removed.length} +${prefix + 1},${added.length} @@`; + const body = [ + ...removed.map((line) => `-${line}`), + ...added.map((line) => `+${line}`), + ]; + return [...header, hunk, ...body].join("\n"); +} + +function diffKind(oldText: string | null | undefined, newText: string): "create" | "modify" | "delete" { + if (oldText === null || oldText === undefined || oldText === "") return "create"; + if (newText === "") return "delete"; + return "modify"; +} + +function slashSignature(commands: AcpSlashCommand[]): string { + return commands.map((command) => `${command.name}\u0000${command.description}`).join("\u0001"); +} + +export function createAcpEventTranslator(options: AcpEventTranslatorOptions = {}): AcpEventTranslator { + const toolCalls = new Map(); + let turnId: string | null = null; + let syntheticMessageCounter = 0; + let activeTextMessageId: string | null = null; + let activeThoughtMessageId: string | null = null; + let lastSlashSignature: string | null = null; + + const withTurn = (event: T): T & { turnId?: string } => + (turnId ? { ...event, turnId } : event) as T & { turnId?: string }; + + const messageIdFor = (kind: "text" | "thought", chunkMessageId: string | null | undefined): string => { + if (typeof chunkMessageId === "string" && chunkMessageId.length) { + if (kind === "text") activeTextMessageId = chunkMessageId; + else activeThoughtMessageId = chunkMessageId; + return chunkMessageId; + } + const current = kind === "text" ? activeTextMessageId : activeThoughtMessageId; + if (current) return current; + syntheticMessageCounter += 1; + const minted = `acp-${kind}-${turnId ?? "no-turn"}-${syntheticMessageCounter}`; + if (kind === "text") activeTextMessageId = minted; + else activeThoughtMessageId = minted; + return minted; + }; + + const emitToolContent = ( + tracked: TrackedToolCall, + toolCallId: string, + content: AcpToolCallContent[], + status: AcpToolCallStatus, + ): AgentChatEvent[] => { + const events: AgentChatEvent[] = []; + for (const item of content) { + if (item.type === "content") { + const text = textOfContentBlock(item.content); + if (text.length) tracked.lastOutput = text; + continue; + } + if (item.type === "terminal") { + // The terminal id is only useful with the `terminal` client capability, + // which ADE does not advertise. Record it so the row is not empty. + if (!tracked.lastOutput.length) tracked.lastOutput = `terminal ${item.terminalId}`; + continue; + } + if (item.type === "diff") { + if (tracked.rowKind !== "file_change") { + // A non-edit tool produced a diff. Keep the text so the tool result + // row still shows what changed. + tracked.lastOutput = buildUnifiedDiff(item.path, item.oldText ?? "", item.newText); + continue; + } + let index = tracked.diffIndexByPath.get(item.path); + if (index === undefined) { + index = tracked.diffIndexByPath.size; + tracked.diffIndexByPath.set(item.path, index); + } + events.push( + withTurn({ + type: "file_change" as const, + path: item.path, + diff: buildUnifiedDiff(item.path, item.oldText ?? "", item.newText), + kind: diffKind(item.oldText, item.newText), + itemId: `${toolCallId}:${index}`, + logicalItemId: toolCallId, + status: toolStatusToAde(status), + }), + ); + continue; + } + assertNever(item, "acp tool call content"); + } + return events; + }; + + const openRow = (tracked: TrackedToolCall, toolCallId: string, rawInput: unknown): AgentChatEvent[] => { + tracked.opened = true; + switch (tracked.rowKind) { + case "command": { + const command = readRawInputString(rawInput, ["command", "cmd", "script", "input"]) || tracked.title; + tracked.cwd = readRawInputString(rawInput, ["cwd", "workdir", "directory"]) || tracked.cwd; + return [ + withTurn({ + type: "command" as const, + command, + cwd: tracked.cwd, + output: tracked.lastOutput, + itemId: toolCallId, + status: toolStatusToAde(tracked.status), + }), + ]; + } + case "file_change": + // The edit row cannot open until a diff arrives; the diff carries the + // path. `emitToolContent` opens it. + return []; + case "tool": + return [ + withTurn({ + type: "tool_call" as const, + tool: tracked.toolName, + args: rawInput ?? {}, + itemId: toolCallId, + }), + ]; + default: + return assertNever(tracked.rowKind, "acp tool row kind"); + } + }; + + const closeRow = ( + tracked: TrackedToolCall, + toolCallId: string, + rawOutput: unknown, + ): AgentChatEvent[] => { + switch (tracked.rowKind) { + case "command": + return [ + withTurn({ + type: "command" as const, + command: tracked.title, + cwd: tracked.cwd, + output: tracked.lastOutput, + itemId: toolCallId, + status: toolStatusToAde(tracked.status), + }), + ]; + case "file_change": + // Every file row already carries its own status from the diff pass. + return []; + case "tool": + return [ + withTurn({ + type: "tool_result" as const, + tool: tracked.toolName, + result: rawOutput ?? tracked.lastOutput, + itemId: toolCallId, + status: toolStatusToAde(tracked.status), + }), + ]; + default: + return assertNever(tracked.rowKind, "acp tool row kind"); + } + }; + + const translate = (update: AcpSessionUpdate): AgentChatEvent[] => { + switch (update.sessionUpdate) { + case "user_message_chunk": + // ADE already owns the user's message. Echoing it would duplicate the + // bubble on every replay. + return []; + + case "agent_message_chunk": { + const text = textOfContentBlock(update.content); + if (!text.length) return []; + const messageId = messageIdFor("text", update.messageId); + return [withTurn({ type: "text" as const, text, messageId, itemId: messageId })]; + } + + case "agent_thought_chunk": { + const text = textOfContentBlock(update.content); + if (!text.length) return []; + const messageId = messageIdFor("thought", update.messageId); + return [withTurn({ type: "reasoning" as const, text, itemId: messageId })]; + } + + case "tool_call": { + const kind = update.kind ?? "other"; + const tracked: TrackedToolCall = { + rowKind: classifyRowKind(kind), + toolName: update.name?.length ? update.name : update.title, + title: update.title, + kind, + status: update.status ?? "pending", + opened: false, + cwd: "", + lastOutput: "", + diffIndexByPath: new Map(), + }; + toolCalls.set(update.toolCallId, tracked); + const events = openRow(tracked, update.toolCallId, update.rawInput); + events.push(...emitToolContent(tracked, update.toolCallId, update.content ?? [], tracked.status)); + if (tracked.status === "completed" || tracked.status === "failed") { + events.push(...closeRow(tracked, update.toolCallId, update.rawOutput)); + } + return events; + } + + case "tool_call_update": { + const tracked = toolCalls.get(update.toolCallId); + if (!tracked) { + // An update for a tool call ADE never saw. Adopt it rather than drop + // it: an agent that restarts mid-turn can skip the opening frame. + const kind = update.kind ?? "other"; + const adopted: TrackedToolCall = { + rowKind: classifyRowKind(kind), + toolName: update.name?.length ? update.name : update.title ?? update.toolCallId, + title: update.title ?? update.toolCallId, + kind, + status: update.status ?? "in_progress", + opened: false, + cwd: "", + lastOutput: "", + diffIndexByPath: new Map(), + }; + toolCalls.set(update.toolCallId, adopted); + const adoptedEvents = openRow(adopted, update.toolCallId, update.rawInput); + adoptedEvents.push( + ...emitToolContent(adopted, update.toolCallId, update.content ?? [], adopted.status), + ); + if (adopted.status === "completed" || adopted.status === "failed") { + adoptedEvents.push(...closeRow(adopted, update.toolCallId, update.rawOutput)); + } + return adoptedEvents; + } + + if (update.title?.length) tracked.title = update.title; + if (update.name?.length) tracked.toolName = update.name; + const previousStatus = tracked.status; + if (update.status) tracked.status = update.status; + + const events: AgentChatEvent[] = []; + if (!tracked.opened) events.push(...openRow(tracked, update.toolCallId, update.rawInput)); + events.push(...emitToolContent(tracked, update.toolCallId, update.content ?? [], tracked.status)); + const becameTerminal = + (tracked.status === "completed" || tracked.status === "failed") + && previousStatus !== tracked.status; + if (becameTerminal) events.push(...closeRow(tracked, update.toolCallId, update.rawOutput)); + return events; + } + + case "plan": + case "plan_update": { + const steps: AgentChatPlanStep[] = update.entries.map((entry) => ({ + text: entry.content, + status: planStatusToAde(entry.status), + })); + return [withTurn({ type: "plan" as const, steps })]; + } + + case "plan_removed": + return [withTurn({ type: "plan" as const, steps: [] })]; + + case "available_commands_update": { + const filter = options.includeSlashCommand ?? (() => true); + const commands: AcpSlashCommand[] = update.availableCommands.filter(filter).map((command) => ({ + name: command.name, + description: command.description, + inputHint: command.input?.hint ?? null, + })); + const signature = slashSignature(commands); + // Grok re-sends this list on almost every turn. Fire the callback only + // when the content actually changed. + if (signature !== lastSlashSignature) { + lastSlashSignature = signature; + options.callbacks?.onSlashCommands?.(commands); + } + return []; + } + + case "current_mode_update": + options.callbacks?.onConfigOptions?.({ options: [], currentModeId: update.currentModeId }); + return []; + + case "config_option_update": + options.callbacks?.onConfigOptions?.({ + options: normalizeAcpConfigOptions(update.configOptions), + currentModeId: null, + }); + return []; + + case "session_info_update": + options.callbacks?.onSessionInfo?.({ + title: update.title ?? null, + updatedAt: update.updatedAt ?? null, + }); + return []; + + case "usage_update": { + const read = options.readUsage; + if (!read) return []; + const sample = read(update); + if (!sample) return []; + options.callbacks?.onUsage?.(sample); + return usageSampleToEvents(sample, turnId); + } + + case "compaction_update": + case "compaction_summary_chunk": + // ADE has no compaction card for ACP providers yet. Dropping these is + // deliberate, and it is recorded in the conformance matrix. + return []; + + default: + return assertNever(update, "acp session update"); + } + }; + + return { + get turnId() { + return turnId; + }, + set turnId(value: string | null) { + turnId = value; + }, + beginTurn: (nextTurnId: string) => { + turnId = nextTurnId; + activeTextMessageId = null; + activeThoughtMessageId = null; + }, + endTurn: () => { + turnId = null; + activeTextMessageId = null; + activeThoughtMessageId = null; + }, + translate, + rowKindFor: (toolCallId: string) => toolCalls.get(toolCallId)?.rowKind ?? null, + resetTurnState: () => { + toolCalls.clear(); + activeTextMessageId = null; + activeThoughtMessageId = null; + }, + }; +} + +/** + * Turn a usage sample into chat events. + * + * A sample with token counts produces a `tokens` event. A sample with context + * occupancy produces a `context_usage` event. A sample can produce both. + */ +export function usageSampleToEvents(sample: AcpUsageSample, turnId: string | null): AgentChatEvent[] { + const events: AgentChatEvent[] = []; + const hasTokens = + sample.inputTokens !== undefined + || sample.outputTokens !== undefined + || sample.cacheReadTokens !== undefined + || sample.cacheWriteTokens !== undefined; + if (hasTokens && turnId) { + events.push({ + type: "tokens", + turnId, + ...(sample.inputTokens !== undefined ? { inputTokens: sample.inputTokens } : {}), + ...(sample.outputTokens !== undefined ? { outputTokens: sample.outputTokens } : {}), + ...(sample.cacheReadTokens !== undefined ? { cacheReadTokens: sample.cacheReadTokens } : {}), + ...(sample.cacheWriteTokens !== undefined ? { cacheWriteTokens: sample.cacheWriteTokens } : {}), + ...(sample.contextWindowTokens !== undefined ? { contextWindow: sample.contextWindowTokens } : {}), + }); + } + if (sample.contextUsedTokens !== undefined && sample.contextWindowTokens) { + const percentage = Math.min( + 100, + Math.max(0, Math.round((sample.contextUsedTokens / sample.contextWindowTokens) * 100)), + ); + events.push({ + type: "context_usage", + origin: "live", + usage: { + categories: [], + totalTokens: sample.contextUsedTokens, + maxTokens: sample.contextWindowTokens, + percentage, + ...(sample.inputTokens !== undefined ? { inputTokens: sample.inputTokens } : {}), + ...(sample.outputTokens !== undefined ? { outputTokens: sample.outputTokens } : {}), + ...(sample.cacheReadTokens !== undefined ? { cacheReadTokens: sample.cacheReadTokens } : {}), + }, + ...(turnId ? { turnId } : {}), + }); + } + return events; +} diff --git a/apps/desktop/src/main/services/chat/acpHost/acpHost.fixtures.test.ts b/apps/desktop/src/main/services/chat/acpHost/acpHost.fixtures.test.ts new file mode 100644 index 0000000000..68f91176cc --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpHost.fixtures.test.ts @@ -0,0 +1,168 @@ +/** + * Dialect claims vs captured initialize responses from real binaries. + * + * These fixtures were recorded on 2026-08-31 against Copilot CLI 1.0.82 + * (ACP agent 1.0.4), Grok 1.0.13, Qwen Code 0.22.3, and Kimi Code 0.39.1. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { copilotDialect, grokDialect, kimiDialect, qwenDialect, readGrokPromptUsage } from "./acpDialects"; +import { normalizeAcpConfigOptions, type AcpInitializeResponse } from "./acpProtocolTypes"; + +const fixturesDir = path.join(__dirname, "fixtures"); + +function loadFixture(name: string): T { + return JSON.parse(readFileSync(path.join(fixturesDir, name), "utf8")) as T; +} + +describe("captured initialize fixtures", () => { + it("copilot 1.0.82 advertises loadSession and image, not close or resume", () => { + const init = loadFixture("copilot.initialize.json"); + expect(init.protocolVersion).toBe(1); + expect(init.agentCapabilities?.loadSession).toBe(true); + expect(init.agentCapabilities?.promptCapabilities?.image).toBe(true); + expect(init.agentCapabilities?.sessionCapabilities?.list).toEqual({}); + expect(init.agentCapabilities?.sessionCapabilities).not.toHaveProperty("close"); + expect(init.agentCapabilities?.sessionCapabilities).not.toHaveProperty("resume"); + // ADE still declares close and degrades on -32601 rather than killing the + // process (Copilot can host more than one session). Resume stays unclaimed. + expect(copilotDialect.closeStyle).toBe("close_request"); + expect(copilotDialect.loadPolicy).toBe("load_only"); + expect(copilotDialect.resumeSession.declared).toBe(false); + expect(copilotDialect.cancelStyle).toBe("notification"); + expect(copilotDialect.imagePrompts.declared).toBe(true); + }); + + it("grok 1.0.13 advertises load/resume/close, no images, and MCP http/sse", () => { + const init = loadFixture("grok.initialize.json"); + expect(init.protocolVersion).toBe(1); + expect(init.agentCapabilities?.loadSession).toBe(true); + expect(init.agentCapabilities?.promptCapabilities?.image).toBe(false); + expect(init.agentCapabilities?.mcpCapabilities).toEqual({ http: true, sse: true }); + expect(init.agentCapabilities?.sessionCapabilities).toMatchObject({ + list: {}, + resume: {}, + close: {}, + }); + expect(grokDialect.loadPolicy).toBe("resume_preferred"); + expect(grokDialect.closeStyle).toBe("close_request"); + expect(grokDialect.imagePrompts.declared).toBe(false); + expect(grokDialect.mcpInjection.declared).toBe(true); + expect(grokDialect.cancelStyle).toBe("notification"); + expect(grokDialect.advertiseFsCapability).toBe(false); + }); + + it("grok usage reader accepts the nested 1.0.13 prompt _meta", () => { + const meta = loadFixture>("grok.promptResult.meta.json"); + const usage = readGrokPromptUsage(meta); + + // Sanity-check dollars against token counts and published rates, not against + // GROK_COST_TICKS_PER_USD. The fixture is 29805 input (5888 cached) + 32 + // output. docs.x.ai/developers/pricing (2026-08-31): grok-4.6 <200k is + // $2.00/M input, $0.50/M cached, $6.00/M output → about $0.051. A ~$3/$15 + // public band on the same tokens is about $0.09. $86.65 is a 1000x + // mis-scale of 86_649_000 ticks, not a real bill. + const uncachedInput = 29_805 - 5_888; + const publishedGrok46Usd = (uncachedInput * 2 + 5_888 * 0.5 + 32 * 6) / 1_000_000; + const threeDollarBandUsd = (29_805 * 3 + 32 * 15) / 1_000_000; + expect(publishedGrok46Usd).toBeGreaterThan(0.04); + expect(publishedGrok46Usd).toBeLessThan(0.07); + expect(threeDollarBandUsd).toBeGreaterThan(0.08); + expect(threeDollarBandUsd).toBeLessThan(0.10); + + expect(usage?.costUsd).toBeCloseTo(0.086649, 5); + expect(usage?.costUsd).toBeGreaterThan(0.04); + expect(usage?.costUsd).toBeLessThan(0.12); + expect(usage).toMatchObject({ + cacheReadTokens: 5888, + inputTokens: 29805, + outputTokens: 32, + reasoningTokens: 27, + totalTokens: 29837, + }); + }); + + it("a later live Grok ping also lands in cents, not hundreds of dollars", () => { + const report = loadFixture<{ + steps: Array<{ + step: string; + usage?: { rawMeta?: Record; inputTokens?: number; outputTokens?: number; cachedReadTokens?: number }; + }>; + }>("grok.followup-probe.json"); + const ping = report.steps.find((step) => step.step === "cheap-ping"); + const meta = ping?.usage?.rawMeta; + expect(meta).toBeTruthy(); + const usage = readGrokPromptUsage(meta); + const input = ping?.usage?.inputTokens ?? 0; + const output = ping?.usage?.outputTokens ?? 0; + const cached = ping?.usage?.cachedReadTokens ?? 0; + const uncached = Math.max(0, input - cached); + // grok-4.5 list: $2.00/M input, $0.30/M cached, $6.00/M output. + const publishedUsd = (uncached * 2 + cached * 0.3 + output * 6) / 1_000_000; + expect(publishedUsd).toBeGreaterThan(0.01); + expect(publishedUsd).toBeLessThan(0.1); + expect(usage?.costUsd).toBeGreaterThan(0.01); + expect(usage?.costUsd).toBeLessThan(1); + }); + + it("copilot config_option_update currentValue maps onto ADE value/id", () => { + const raw = loadFixture("copilot.config-options.json"); + const options = normalizeAcpConfigOptions(raw); + const mode = options.find((option) => option.id === "mode"); + expect(mode?.value).toBe("https://agentclientprotocol.com/protocol/session-modes#agent"); + expect(mode?.options?.map((entry) => entry.id)).toEqual([ + "https://agentclientprotocol.com/protocol/session-modes#agent", + "https://agentclientprotocol.com/protocol/session-modes#plan", + ]); + expect(options.find((option) => option.id === "allow_all")?.value).toBe("off"); + }); + + it("qwen 0.22.3 advertises resume and image/audio, not close", () => { + const init = loadFixture("qwen.initialize.json"); + expect(init.protocolVersion).toBe(1); + expect(init.agentInfo?.version).toBe("0.22.3"); + expect(init.agentCapabilities?.loadSession).toBe(true); + expect(init.agentCapabilities?.promptCapabilities).toEqual({ + image: true, + audio: true, + embeddedContext: true, + }); + expect(init.agentCapabilities?.mcpCapabilities).toEqual({ sse: true, http: true }); + expect(init.agentCapabilities?.sessionCapabilities).toEqual({ list: {}, resume: {} }); + expect(init.agentCapabilities?.sessionCapabilities).not.toHaveProperty("close"); + expect(init.authMethods?.map((method) => method.id)).toEqual(["openai"]); + expect(qwenDialect.closeStyle).toBe("kill_process"); + expect(qwenDialect.oneProcessPerSession).toBe(true); + expect(qwenDialect.loadPolicy).toBe("resume_preferred"); + expect(qwenDialect.imagePrompts.declared).toBe(true); + expect(qwenDialect.authProbe.methodId).toBe("openai"); + }); + + it("kimi 0.39.1 advertises close, login terminal-auth, and no usage", () => { + const init = loadFixture("kimi.initialize.json"); + expect(init.protocolVersion).toBe(1); + expect(init.agentInfo?.version).toBe("0.39.1"); + expect(init.agentCapabilities?.loadSession).toBe(true); + expect(init.agentCapabilities?.promptCapabilities).toEqual({ + image: true, + audio: false, + embeddedContext: true, + }); + expect(init.agentCapabilities?.mcpCapabilities).toEqual({ http: true, sse: true }); + expect(init.agentCapabilities?.sessionCapabilities).toMatchObject({ + list: {}, + resume: {}, + close: {}, + }); + expect(init.authMethods?.[0]).toMatchObject({ + id: "login", + type: "terminal", + }); + expect(kimiDialect.closeStyle).toBe("close_request"); + expect(kimiDialect.oneProcessPerSession).toBe(false); + expect(kimiDialect.usageSource).toBe("none"); + expect(kimiDialect.authProbe.methodId).toBe("login"); + expect(kimiDialect.imagePrompts.declared).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpHost.live.test.ts b/apps/desktop/src/main/services/chat/acpHost/acpHost.live.test.ts new file mode 100644 index 0000000000..b398799b84 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpHost.live.test.ts @@ -0,0 +1,301 @@ +/** + * Live handshake against installed ACP binaries, through ADE's own host. + * + * Skipped unless ACP_LIVE_PROBE=1. CI has no credentials and must not spawn + * vendor CLIs. The verification brief runs this locally. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentChatEvent } from "../../../../shared/types"; +import { resolveAcpExecutable } from "../../ai/acpExecutables"; +import { isAcpAuthError } from "../../ai/acpAuthProbe"; +import { loadQwenUserSettings } from "../../ai/qwenUserSettings"; +import { copilotConfigHome, qwenConfigHome } from "../../shared/providerConfigHomes"; +import { copilotDialect, grokDialect, kimiDialect, qwenDialect } from "./acpDialects"; +import { createAcpConnection, initializeAcpConnection } from "./acpConnection"; +import { ACP_METHOD } from "./acpProtocolTypes"; +import { openAcpSession, textPromptBlock, type AcpSession } from "./acpSession"; +import { createAcpSessionPool } from "./acpSessionPool"; + +const LIVE = process.env.ACP_LIVE_PROBE === "1"; +const LIVE_DEADLINE_MS = 45_000; +const cwd = process.cwd(); + +const pools: Array<{ disposeAll: (reason: string) => void }> = []; +const sessions: AcpSession[] = []; + +afterEach(async () => { + for (const session of sessions.splice(0)) { + await session.close("live test teardown").catch(() => undefined); + } + for (const pool of pools.splice(0)) pool.disposeAll("live test teardown"); +}); + +async function withDeadline(label: string, promise: Promise, ms = LIVE_DEADLINE_MS): Promise { + let timer: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} did not settle within ${ms}ms`)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +describe.skipIf(!LIVE)("ACP host live handshake", () => { + it("opens a Copilot session through ADE's host without deadlocking on trust", async () => { + const executable = resolveAcpExecutable("copilot"); + expect(executable.source).not.toBe("fallback-command"); + const pool = createAcpSessionPool(); + pools.push(pool); + const dialect = copilotDialect; + const spawnPlan = dialect.buildSpawnPlan({ + binaryPath: executable.path, + cwd, + baseEnv: process.env, + configHome: copilotConfigHome(), + }); + const session = await withDeadline( + "copilot open", + openAcpSession({ + dialect, + cwd, + spawnPlan, + sessionToken: "live-copilot", + pool, + callbacks: { + onEvents: () => undefined, + onPermissionRequested: (pending) => pending.cancel(), + onPermissionSettled: () => undefined, + }, + }), + ); + sessions.push(session); + expect(session.sessionId.length).toBeGreaterThan(0); + expect(session.connection.initializeResult?.agentCapabilities?.loadSession).toBe(true); + expect(session.connection.isAlive()).toBe(true); + }); + + it("opens a Grok session through ADE's host and stamps permission-mode on spawn", async () => { + const executable = resolveAcpExecutable("grok"); + expect(executable.source).not.toBe("fallback-command"); + const pool = createAcpSessionPool(); + pools.push(pool); + const dialect = grokDialect; + const spawnPlan = dialect.buildSpawnPlan({ + binaryPath: executable.path, + cwd, + baseEnv: process.env, + permissionMode: "default", + }); + expect(spawnPlan.args).toEqual(expect.arrayContaining(["--permission-mode", "default"])); + const session = await withDeadline( + "grok open", + openAcpSession({ + dialect, + cwd, + spawnPlan, + sessionToken: "live-grok", + pool, + callbacks: { + onEvents: () => undefined, + onPermissionRequested: (pending) => pending.cancel(), + onPermissionSettled: () => undefined, + }, + }), + ); + sessions.push(session); + expect(session.sessionId.length).toBeGreaterThan(0); + expect(session.connection.initializeResult?.agentCapabilities?.sessionCapabilities).toMatchObject({ + resume: {}, + close: {}, + }); + }); + + it("runs a Copilot ping through ADE's host from a tiny cwd", async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), "ade-acp-live-copilot-")); + writeFileSync(path.join(tmp, "README.md"), "probe\n"); + try { + const executable = resolveAcpExecutable("copilot"); + const pool = createAcpSessionPool(); + pools.push(pool); + const events: AgentChatEvent[] = []; + const spawnPlan = copilotDialect.buildSpawnPlan({ + binaryPath: executable.path, + cwd: tmp, + baseEnv: process.env, + configHome: copilotConfigHome(), + }); + const session = await withDeadline( + "copilot open tiny", + openAcpSession({ + dialect: copilotDialect, + cwd: tmp, + spawnPlan, + sessionToken: "live-copilot-ping", + pool, + callbacks: { + onEvents: (batch) => events.push(...batch), + onPermissionRequested: (pending) => pending.cancel(), + onPermissionSettled: () => undefined, + }, + }), + ); + sessions.push(session); + const outcome = await withDeadline( + "copilot ping", + session.prompt({ + turnId: "live-ping", + blocks: [textPromptBlock("Reply with exactly the word ping and nothing else. Do not use tools.")], + }), + 60_000, + ); + expect(outcome.stopReason).toBe("end_turn"); + expect(events.some((event) => event.type === "text" && event.text.toLowerCase().includes("ping"))).toBe(true); + expect(outcome.usage?.inputTokens).toBeGreaterThan(0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("qwen initialize works without auth; session/new is the re-login card", async () => { + const executable = resolveAcpExecutable("qwen"); + expect(executable.source).not.toBe("fallback-command"); + const tmp = mkdtempSync(path.join(os.tmpdir(), "ade-acp-live-qwen-")); + const configHome = path.join(tmp, "qwen-home"); + const fakeHome = path.join(tmp, "home"); + mkdirSync(configHome, { recursive: true }); + mkdirSync(fakeHome, { recursive: true }); + const env: NodeJS.ProcessEnv = { ...process.env, QWEN_HOME: configHome, HOME: fakeHome }; + delete env.OPENAI_API_KEY; + delete env.DASHSCOPE_API_KEY; + delete env.QWEN_API_KEY; + const spawnPlan = qwenDialect.buildSpawnPlan({ + binaryPath: executable.path, + cwd: tmp, + baseEnv: env, + configHome, + }); + const connection = createAcpConnection({ dialect: qwenDialect, spawnPlan }); + try { + const { response } = await withDeadline( + "qwen initialize", + initializeAcpConnection({ connection, dialect: qwenDialect }), + ); + expect(response.agentCapabilities?.loadSession).toBe(true); + expect(response.agentCapabilities?.sessionCapabilities).not.toHaveProperty("close"); + expect(response.authMethods?.map((method) => method.id)).toEqual(["openai"]); + await expect( + connection.request(ACP_METHOD.sessionNew, { cwd: tmp, mcpServers: [] }), + ).rejects.toSatisfy((error: unknown) => isAcpAuthError(error)); + } finally { + connection.dispose("live qwen unauth"); + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("kimi initialize works without auth; session/new is the re-login card", async () => { + const executable = resolveAcpExecutable("kimi"); + expect(executable.source).not.toBe("fallback-command"); + const tmp = mkdtempSync(path.join(os.tmpdir(), "ade-acp-live-kimi-")); + const configHome = path.join(tmp, "kimi-home"); + const fakeHome = path.join(tmp, "home"); + mkdirSync(configHome, { recursive: true }); + mkdirSync(fakeHome, { recursive: true }); + const env: NodeJS.ProcessEnv = { + ...process.env, + KIMI_CODE_HOME: configHome, + HOME: fakeHome, + PATH: `${path.join(os.homedir(), ".kimi-code", "bin")}:${process.env.PATH ?? ""}`, + }; + delete env.MOONSHOT_API_KEY; + delete env.KIMI_API_KEY; + const spawnPlan = kimiDialect.buildSpawnPlan({ + binaryPath: executable.path, + cwd: tmp, + baseEnv: env, + configHome, + }); + const connection = createAcpConnection({ dialect: kimiDialect, spawnPlan }); + try { + const { response } = await withDeadline( + "kimi initialize", + initializeAcpConnection({ connection, dialect: kimiDialect }), + ); + expect(response.agentCapabilities?.sessionCapabilities).toMatchObject({ + list: {}, + resume: {}, + close: {}, + }); + expect(response.authMethods?.[0]).toMatchObject({ id: "login", type: "terminal" }); + await expect( + connection.request(ACP_METHOD.sessionNew, { cwd: tmp, mcpServers: [] }), + ).rejects.toSatisfy((error: unknown) => isAcpAuthError(error)); + } finally { + connection.dispose("live kimi unauth"); + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("runs a Qwen ping through ADE's host with this machine's Qwen settings", async () => { + const executable = resolveAcpExecutable("qwen"); + expect(executable.source).not.toBe("fallback-command"); + const settings = await loadQwenUserSettings(); + expect(settings.authenticated).toBe(true); + const tmp = mkdtempSync(path.join(os.tmpdir(), "ade-acp-live-qwen-auth-")); + writeFileSync(path.join(tmp, "README.md"), "probe\n"); + try { + const events: AgentChatEvent[] = []; + const pool = createAcpSessionPool(); + pools.push(pool); + const spawnPlan = qwenDialect.buildSpawnPlan({ + binaryPath: executable.path, + cwd: tmp, + baseEnv: process.env, + configHome: qwenConfigHome(), + }); + const session = await withDeadline( + "qwen open", + openAcpSession({ + dialect: qwenDialect, + cwd: tmp, + spawnPlan, + sessionToken: "live-qwen-ping", + pool, + callbacks: { + onEvents: (batch) => events.push(...batch), + onPermissionRequested: (pending) => pending.cancel(), + onPermissionSettled: () => undefined, + }, + }), + 60_000, + ); + sessions.push(session); + expect(session.sessionId.length).toBeGreaterThan(0); + if (settings.defaultModelId) { + await session.setConfigOption({ configId: "model", value: settings.defaultModelId }); + } + const outcome = await withDeadline( + "qwen ping", + session.prompt({ + turnId: "live-qwen-ping", + blocks: [textPromptBlock("Reply with exactly the word ping and nothing else. Do not use tools.")], + }), + 90_000, + ); + expect(outcome.stopReason).toBe("end_turn"); + expect(events.some((event) => event.type === "text" && event.text.toLowerCase().includes("ping"))).toBe(true); + expect( + settings.models.some((model) => model.id === settings.defaultModelId) + || Boolean(settings.defaultModelId), + ).toBe(true); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts b/apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts new file mode 100644 index 0000000000..efeb332e0e --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpHost.test.ts @@ -0,0 +1,1836 @@ +/** + * ACP host conformance suite. + * + * The core of this file is a `feature x dialect` table. Every cell must show + * one of two outcomes: + * + * run — the feature works end to end. + * degrade — the feature is absent, and the host says so without throwing and + * without hanging. + * + * Every test has a deadline. A hang is a failure, not a slow pass. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ACP_METHOD, + ACP_RPC_METHOD_NOT_FOUND, + normalizeAcpPermissionRequest, + normalizeAcpRpcError, + normalizeAcpRpcId, + normalizeAcpSessionNotification, + type AcpContentBlock, + type AcpSessionConfigOption, + type AcpSessionUpdate, +} from "./acpProtocolTypes"; +import { + ACP_DIALECTS, + acpDialectFor, + copilotDialect, + grokDialect, + GROK_CLAUDE_MARKER_OVERRIDE_ENV, + GROK_SESSION_NOTIFICATION_METHOD, + GROK_YOLO_MODE_CHANGED_METHOD, + includeCopilotSlashCommand, + kimiDialect, + qwenDialect, + readGrokPromptUsage, +} from "./acpDialects"; +import { + ACP_PROVIDER_IDS, + behaviorOf, + type AcpDialect, + type AcpProviderId, + type AcpSlashCommand, +} from "./acpHostTypes"; +import { createAcpConnection, initializeAcpConnection, AcpRpcError } from "./acpConnection"; +import { + buildAcpPoolKey, + createAcpSessionPool, + hashPoolEnv, + hashSpawnInvocation, +} from "./acpSessionPool"; +import { buildUnifiedDiff, createAcpEventTranslator } from "./acpEventTranslator"; +import { + createAcpPermissionBridge, + normalizePermissionOption, + pendingPermissionToInputRequest, + type AcpPendingPermission, +} from "./acpPermissionBridge"; +import { openAcpSession, resolveAcpSessionEntry, textPromptBlock } from "./acpSession"; +import { createMockAcpAgent, respondWithSession, type MockAcpAgent } from "./mockAcpAgent"; +import type { AgentChatEvent } from "../../../../shared/types"; + +const DEADLINE_MS = 3_000; + +/** Fail loudly rather than let a hang become a slow pass. */ +async function withDeadline(label: string, promise: Promise, ms = DEADLINE_MS): Promise { + let timer: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} did not settle within ${ms}ms`)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +type Harness = { + agent: MockAcpAgent; + events: AgentChatEvent[]; + permissions: AcpPendingPermission[]; + settled: Array<{ requestId: string; outcome: string }>; + slashLists: AcpSlashCommand[][]; + exits: Array<{ code: number | null }>; + open: (overrides?: Partial[0]>) => ReturnType; +}; + +const openHarnesses: Array<() => void> = []; + +function makeHarness(dialect: AcpDialect, agentOverrides: Parameters[0] = {}): Harness { + const agent = createMockAcpAgent(agentOverrides); + agent.on(ACP_METHOD.sessionNew, respondWithSession("session-1")); + const events: AgentChatEvent[] = []; + const permissions: AcpPendingPermission[] = []; + const settled: Array<{ requestId: string; outcome: string }> = []; + const slashLists: AcpSlashCommand[][] = []; + const exits: Array<{ code: number | null }> = []; + const pool = createAcpSessionPool(); + openHarnesses.push(() => pool.disposeAll("test teardown")); + + return { + agent, + events, + permissions, + settled, + slashLists, + exits, + open: (overrides = {}) => + openAcpSession({ + dialect, + cwd: "/lane/worktree", + spawnPlan: dialect.buildSpawnPlan({ + binaryPath: `/usr/local/bin/${dialect.binaryNames[0]}`, + cwd: "/lane/worktree", + baseEnv: {}, + }), + sessionToken: "chat-1", + pool, + spawnOverride: () => agent.child, + callbacks: { + onEvents: (batch) => events.push(...batch), + onPermissionRequested: (pending) => permissions.push(pending), + onPermissionSettled: (requestId, outcome) => settled.push({ requestId, outcome }), + onSlashCommands: (commands) => slashLists.push(commands), + onProcessExit: (detail) => exits.push({ code: detail.code }), + }, + ...overrides, + }), + }; +} + +afterEach(() => { + for (const dispose of openHarnesses.splice(0)) dispose(); + vi.useRealTimers(); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Capability declarations +// ───────────────────────────────────────────────────────────────────────────── + +describe("dialect capability declarations", () => { + it("registers exactly the four ACP providers", () => { + expect(Object.keys(ACP_DIALECTS).sort()).toEqual([...ACP_PROVIDER_IDS].sort()); + for (const providerId of ACP_PROVIDER_IDS) { + expect(acpDialectFor(providerId).providerId).toBe(providerId); + } + }); + + it.each(ACP_PROVIDER_IDS)("%s honors the requiresBehavior invariant", (providerId: AcpProviderId) => { + const dialect = acpDialectFor(providerId); + // A declared capability carries its behavior. The type system enforces this + // at the definition site; this check protects against a later cast. + for (const entry of [ + dialect.usage, + dialect.closeSession, + dialect.resumeSession, + dialect.loadSession, + dialect.sessionConfig, + dialect.mcpInjection, + dialect.imagePrompts, + ]) { + if (entry.declared) expect(typeof entry.behavior).toBe("function"); + else expect(entry).not.toHaveProperty("behavior"); + } + // Style fields and capabilities agree. + expect(dialect.usage.declared).toBe(dialect.usageSource !== "none"); + expect(dialect.closeSession.declared).toBe(dialect.closeStyle === "close_request"); + expect(dialect.resumeSession.declared).toBe(dialect.loadPolicy === "resume_preferred"); + expect(dialect.loadSession.declared).toBe(dialect.loadPolicy !== "never"); + }); + + it.each(ACP_PROVIDER_IDS)("%s never advertises the client file system", (providerId: AcpProviderId) => { + // Grok corrupts binary reads proxied through the text file system, and ADE + // does not serve file reads for any ACP provider today. + expect(acpDialectFor(providerId).advertiseFsCapability).toBe(false); + }); + + it("qwen owns one process per session because 0.22.3 has no session/close", () => { + expect(qwenDialect.closeStyle).toBe("kill_process"); + expect(qwenDialect.oneProcessPerSession).toBe(true); + expect(qwenDialect.authProbe.methodId).toBe("openai"); + }); + + it("kimi 0.39.1 implements session/close and still hides usage", () => { + expect(kimiDialect.closeStyle).toBe("close_request"); + expect(kimiDialect.oneProcessPerSession).toBe(false); + expect(kimiDialect.usageSource).toBe("none"); + expect(kimiDialect.degradationNotes.length).toBeGreaterThan(0); + expect(kimiDialect.authProbe.methodId).toBe("login"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Spawn plans +// ───────────────────────────────────────────────────────────────────────────── + +describe("spawn plans", () => { + it("grok places global flags before agent and scoped flags after it", () => { + const plan = grokDialect.buildSpawnPlan({ + binaryPath: "/bin/grok", + cwd: "/lane", + baseEnv: {}, + modelId: "grok-4", + reasoningEffort: "high", + }); + expect(plan.args).toEqual([ + "--no-auto-update", + "--no-plan", + "--permission-mode", + "default", + "agent", + "--no-leader", + "-m", + "grok-4", + "--reasoning-effort", + "high", + "stdio", + ]); + expect(plan.args.indexOf("--no-auto-update")).toBeLessThan(plan.args.indexOf("agent")); + expect(plan.args.indexOf("--permission-mode")).toBeLessThan(plan.args.indexOf("agent")); + expect(plan.args.indexOf("--no-leader")).toBeGreaterThan(plan.args.indexOf("agent")); + expect(plan.args[plan.args.length - 1]).toBe("stdio"); + }); + + it("grok carries both halves of the neutralization on every spawn", () => { + // Neither half works alone: `--permission-mode` only overrides + // `~/.grok/config.toml`, and the marker override only cancels the Claude + // settings import. A live six-arm probe on 1.0.13 proved dropping either + // one re-opens the auto-approval hole, so they are asserted together. + for (const permissionMode of [null, "plan", "default", "auto-edit", "auto", "yolo"]) { + const plan = grokDialect.buildSpawnPlan({ + binaryPath: "/bin/grok", + cwd: "/lane", + baseEnv: { PATH: "/bin" }, + permissionMode, + }); + expect(plan.args).toContain("--permission-mode"); + expect(plan.env[GROK_CLAUDE_MARKER_OVERRIDE_ENV]).toBe("1"); + expect(plan.env.PATH).toBe("/bin"); + } + }); + + it("grok always stamps --permission-mode, because yolo_mode_changed is dead on 1.0.13", () => { + expect( + grokDialect.buildSpawnPlan({ binaryPath: "/bin/grok", cwd: "/lane", baseEnv: {} }).args, + ).toEqual(expect.arrayContaining(["--permission-mode", "default"])); + expect( + grokDialect.buildSpawnPlan({ + binaryPath: "/bin/grok", + cwd: "/lane", + baseEnv: {}, + permissionMode: "yolo", + }).args, + ).toEqual(expect.arrayContaining(["--permission-mode", "bypassPermissions"])); + expect( + grokDialect.buildSpawnPlan({ + binaryPath: "/bin/grok", + cwd: "/lane", + baseEnv: {}, + permissionMode: "auto-edit", + }).args, + ).toEqual(expect.arrayContaining(["--permission-mode", "acceptEdits"])); + }); + + it("grok sets no config home, even though GROK_HOME is a real override", () => { + // `xai-dirs` honors GROK_HOME. ADE declines it on purpose: a private home + // would hide the user's own `grok login` credential and rules. + const plan = grokDialect.buildSpawnPlan({ binaryPath: "/bin/grok", cwd: "/lane", baseEnv: { PATH: "/bin" } }); + expect(plan.env.PATH).toBe("/bin"); + expect(plan.env.GROK_HOME).toBeUndefined(); + expect(plan.env.QWEN_HOME).toBeUndefined(); + expect(plan.env.KIMI_CODE_HOME).toBeUndefined(); + expect(plan.env.COPILOT_HOME).toBeUndefined(); + }); + + it("qwen exports QWEN_HOME only when a config home exists", () => { + expect( + qwenDialect.buildSpawnPlan({ binaryPath: "/bin/qwen", cwd: "/lane", baseEnv: {} }).env.QWEN_HOME, + ).toBeUndefined(); + expect( + qwenDialect.buildSpawnPlan({ + binaryPath: "/bin/qwen", + cwd: "/lane", + baseEnv: {}, + configHome: "/home/.qwen", + }).env.QWEN_HOME, + ).toBe("/home/.qwen"); + }); + + it("kimi spawns the acp subcommand and exports KIMI_CODE_HOME", () => { + const plan = kimiDialect.buildSpawnPlan({ + binaryPath: "/bin/kimi", + cwd: "/lane", + baseEnv: {}, + configHome: "/home/.kimi-code", + }); + expect(plan.args).toEqual(["acp"]); + expect(plan.env.KIMI_CODE_HOME).toBe("/home/.kimi-code"); + }); + + it("copilot gates the lane path through argv, not through the config file", () => { + const plan = copilotDialect.buildSpawnPlan({ + binaryPath: "/bin/copilot", + cwd: "/lane/worktree", + baseEnv: {}, + configHome: "/home/.copilot", + }); + expect(plan.args).toContain("--acp"); + expect(plan.args).toContain("--add-dir"); + expect(plan.args[plan.args.indexOf("--add-dir") + 1]).toBe("/lane/worktree"); + expect(plan.args).toContain("--config-dir"); + expect(plan.env.COPILOT_HOME).toBe("/home/.copilot"); + }); + + // ADE removed its Copilot trust pre-seed: a live three-arm experiment on + // 1.0.82 opened `session/new` with no trust key and no `--add-dir`, and the + // JSONC rewrite the seed needed once replaced a user's real + // `~/.copilot/config.json` with a stub. Nothing on the Copilot path may + // write the provider's config home again. + it("copilot leaves the config home byte-identical when a session opens", async () => { + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ade-copilot-")); + const configPath = path.join(home, "config.json"); + const live = `// User settings belong in settings.json.\n// This file is managed automatically.\n{\n "trustedFolders": ["/already"],\n "firstLaunchAt": "2026-03-11T00:00:00.000Z"\n}\n`; + try { + fs.writeFileSync(configPath, live, "utf8"); + const harness = makeHarness(copilotDialect); + await withDeadline( + "open", + harness.open({ + spawnPlan: copilotDialect.buildSpawnPlan({ + binaryPath: "/bin/copilot", + cwd: "/lane/worktree", + baseEnv: {}, + configHome: home, + }), + }), + ); + expect(fs.readdirSync(home)).toEqual(["config.json"]); + expect(fs.readFileSync(configPath, "utf8")).toBe(live); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("copilot creates nothing in an empty config home", async () => { + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ade-copilot-")); + try { + const harness = makeHarness(copilotDialect); + await withDeadline( + "open", + harness.open({ + spawnPlan: copilotDialect.buildSpawnPlan({ + binaryPath: "/bin/copilot", + cwd: "/lane/worktree", + baseEnv: {}, + configHome: home, + }), + }), + ); + expect(fs.readdirSync(home)).toEqual([]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Handshake and lifecycle +// ───────────────────────────────────────────────────────────────────────────── + +describe("handshake", () => { + it.each(ACP_PROVIDER_IDS)("%s completes initialize with honest capabilities", async (providerId: AcpProviderId) => { + const dialect = acpDialectFor(providerId); + const agent = createMockAcpAgent(); + const connection = createAcpConnection({ + dialect, + spawnPlan: dialect.buildSpawnPlan({ binaryPath: "/bin/x", cwd: "/lane", baseEnv: {} }), + spawnOverride: () => agent.child, + }); + const result = await withDeadline("initialize", initializeAcpConnection({ connection, dialect })); + expect(result.protocolVersionAccepted).toBe(true); + const request = agent.received.find((entry) => entry.method === ACP_METHOD.initialize); + const params = request?.params as Record; + expect(params.protocolVersion).toBe(1); + expect((params.clientCapabilities as Record).fs).toBeUndefined(); + connection.dispose("test finished"); + }); + + it("stamps the grok client identifier at initialize", async () => { + const agent = createMockAcpAgent(); + const connection = createAcpConnection({ + dialect: grokDialect, + spawnPlan: grokDialect.buildSpawnPlan({ binaryPath: "/bin/grok", cwd: "/lane", baseEnv: {} }), + spawnOverride: () => agent.child, + }); + await withDeadline("initialize", initializeAcpConnection({ connection, dialect: grokDialect })); + const params = agent.received[0]?.params as Record; + expect(params._meta).toEqual({ clientIdentifier: "ade" }); + connection.dispose("test finished"); + }); + + it("survives a banner line and an unparsable line on stdout", async () => { + const agent = createMockAcpAgent({ bannerLine: "qwen 1.2.3 - update available" }); + const connection = createAcpConnection({ + dialect: qwenDialect, + spawnPlan: qwenDialect.buildSpawnPlan({ binaryPath: "/bin/qwen", cwd: "/lane", baseEnv: {} }), + spawnOverride: () => agent.child, + }); + agent.writeRaw("{ not json at all\n"); + await withDeadline("initialize", initializeAcpConnection({ connection, dialect: qwenDialect })); + expect(connection.isAlive()).toBe(true); + connection.dispose("test finished"); + }); + + it("rejects every pending request when the process exits", async () => { + const agent = createMockAcpAgent(); + const connection = createAcpConnection({ + dialect: qwenDialect, + spawnPlan: qwenDialect.buildSpawnPlan({ binaryPath: "/bin/qwen", cwd: "/lane", baseEnv: {} }), + spawnOverride: () => agent.child, + }); + await withDeadline("initialize", initializeAcpConnection({ connection, dialect: qwenDialect })); + // A request the agent accepts but never answers, so the exit is what + // settles it rather than a -32601. + agent.on(ACP_METHOD.sessionList, () => new Promise(() => undefined)); + const pending = connection.request(ACP_METHOD.sessionList); + await agent.waitForMethod(ACP_METHOD.sessionList); + agent.exit(1); + await withDeadline("rejection", expect(pending).rejects.toThrow(/closed/i)); + }); + + it("answers an agent request the client never advertised, instead of hanging", async () => { + const agent = createMockAcpAgent(); + const connection = createAcpConnection({ + dialect: qwenDialect, + spawnPlan: qwenDialect.buildSpawnPlan({ binaryPath: "/bin/qwen", cwd: "/lane", baseEnv: {} }), + spawnOverride: () => agent.child, + }); + await withDeadline("initialize", initializeAcpConnection({ connection, dialect: qwenDialect })); + await withDeadline( + "fs read rejection", + expect(agent.callClient(ACP_METHOD.fsReadTextFile, { path: "/x" })).rejects.toThrow(/does not implement/i), + ); + connection.dispose("test finished"); + }); +}); + +describe("protocol boundary normalization", () => { + it("accepts only usable JSON-RPC ids and error payloads", () => { + expect(normalizeAcpRpcId("request-1")).toBe("request-1"); + expect(normalizeAcpRpcId(3)).toBe(3); + expect(normalizeAcpRpcId(Number.NaN)).toBeUndefined(); + expect(normalizeAcpRpcId(null)).toBeUndefined(); + expect(normalizeAcpRpcError({ code: -32000, message: "failed", data: { retry: true } })).toEqual({ + code: -32000, + message: "failed", + data: { retry: true }, + }); + expect(normalizeAcpRpcError({ code: "-32000", message: "failed" })).toBeNull(); + }); + + it("drops malformed session updates before dispatch", () => { + expect(normalizeAcpSessionNotification({ + sessionId: "session-1", + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hello" } }, + })).toMatchObject({ sessionId: "session-1" }); + expect(normalizeAcpSessionNotification({ + sessionId: "session-1", + update: { sessionUpdate: "agent_message_chunk" }, + })).toBeNull(); + expect(normalizeAcpSessionNotification({ + sessionId: "session-1", + update: { sessionUpdate: "unknown_update" }, + })).toBeNull(); + }); + + it("requires a complete permission payload before creating a pending card", () => { + expect(normalizeAcpPermissionRequest({ + sessionId: "session-1", + toolCall: { toolCallId: "tool-1", title: "Write file" }, + options: [{ optionId: "allow", name: "Allow" }], + })).toMatchObject({ sessionId: "session-1", options: [{ optionId: "allow" }] }); + expect(normalizeAcpPermissionRequest({ + sessionId: "session-1", + toolCall: { title: "Write file" }, + options: [{ optionId: "allow", name: "Allow" }], + })).toBeNull(); + expect(normalizeAcpPermissionRequest({ + sessionId: "session-1", + toolCall: { toolCallId: "tool-1" }, + options: [{ optionId: "allow" }], + })).toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Session entry policy +// ───────────────────────────────────────────────────────────────────────────── + +describe("session entry policy", () => { + it("starts a new session when there is no stored id", () => { + expect( + resolveAcpSessionEntry({ dialect: qwenDialect, existingSessionId: null, adeHasTranscript: true }).mode, + ).toBe("new"); + }); + + it("prefers resume where the dialect advertises it", () => { + const plan = resolveAcpSessionEntry({ + dialect: qwenDialect, + existingSessionId: "s1", + adeHasTranscript: true, + }); + expect(plan.mode).toBe("resume"); + expect(plan.suppressReplay).toBe(false); + }); + + it("suppresses the load replay when ADE already holds the transcript", () => { + const plan = resolveAcpSessionEntry({ + dialect: copilotDialect, + existingSessionId: "s1", + adeHasTranscript: true, + }); + expect(plan.mode).toBe("load"); + expect(plan.suppressReplay).toBe(true); + }); + + it("keeps the load replay when ADE has no transcript to duplicate", () => { + const plan = resolveAcpSessionEntry({ + dialect: copilotDialect, + existingSessionId: "s1", + adeHasTranscript: false, + }); + expect(plan.suppressReplay).toBe(false); + }); + + it("drops every update a suppressed load replays", async () => { + const harness = makeHarness(copilotDialect); + harness.agent.on(ACP_METHOD.sessionLoad, (_params, agent) => { + agent.emitUpdate("s1", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "replayed history" }, + messageId: "m1", + }); + return { result: {} }; + }); + const session = await withDeadline( + "open", + harness.open({ existingSessionId: "s1", adeHasTranscript: true }), + ); + expect(session.entryPlan.mode).toBe("load"); + expect(harness.events).toHaveLength(0); + + // Live updates after the load still arrive. + harness.agent.emitUpdate("s1", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "live" }, + messageId: "m2", + }); + await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + expect(harness.events.some((event) => event.type === "text" && event.text === "live")).toBe(true); + }); + + it("sends the grok auto-mode neutralizer after session/new, not before", async () => { + const harness = makeHarness(grokDialect); + await withDeadline("open", harness.open()); + const methods = harness.agent.methodsReceived(); + expect(methods).toContain(GROK_YOLO_MODE_CHANGED_METHOD); + expect(methods.indexOf(GROK_YOLO_MODE_CHANGED_METHOD)).toBeGreaterThan( + methods.indexOf(ACP_METHOD.sessionNew), + ); + const notification = harness.agent.received.find( + (entry) => entry.method === GROK_YOLO_MODE_CHANGED_METHOD, + ); + expect(notification?.isNotification).toBe(true); + expect(notification?.params).toMatchObject({ auto_mode: false, permission_mode: "ask" }); + }); + + it.each(ACP_PROVIDER_IDS.filter((id) => id !== "grok"))( + "%s sends no post-session-new notifications", + async (providerId: AcpProviderId) => { + const harness = makeHarness(acpDialectFor(providerId)); + await withDeadline("open", harness.open()); + expect(harness.agent.methodsReceived()).toEqual([ACP_METHOD.initialize, ACP_METHOD.sessionNew]); + }, + ); + + it("receives and ignores the grok spinner hint", async () => { + const harness = makeHarness(grokDialect); + await withDeadline("open", harness.open()); + harness.agent.emitNotification(GROK_SESSION_NOTIFICATION_METHOD, { + pending_interaction: { kind: "permission" }, + }); + await new Promise((resolve) => setImmediate(resolve)); + // A spinner hint is not a permission request. No card, and no answer. + expect(harness.permissions).toHaveLength(0); + expect(harness.events).toHaveLength(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Streaming +// ───────────────────────────────────────────────────────────────────────────── + +describe("stream translation", () => { + function translateAll(updates: AcpSessionUpdate[], turnId = "turn-1"): AgentChatEvent[] { + const translator = createAcpEventTranslator(); + translator.beginTurn(turnId); + return updates.flatMap((update) => translator.translate(update)); + } + + it("keeps one message id across chunks that carry it", () => { + const events = translateAll([ + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hello " }, messageId: "m1" }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "world" }, messageId: "m1" }, + ]); + expect(events).toHaveLength(2); + expect(events.every((event) => event.type === "text" && event.messageId === "m1")).toBe(true); + }); + + it("synthesizes one stable id for a stream that carries none", () => { + const events = translateAll([ + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "a" } }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "b" } }, + ]); + const ids = events.map((event) => (event.type === "text" ? event.messageId : null)); + expect(ids[0]).toBeTruthy(); + expect(ids[0]).toBe(ids[1]); + }); + + it("starts a new row when the message id changes", () => { + const events = translateAll([ + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "a" }, messageId: "m1" }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "b" }, messageId: "m2" }, + ]); + expect(events[0]).toMatchObject({ messageId: "m1" }); + expect(events[1]).toMatchObject({ messageId: "m2" }); + }); + + it("maps a thought chunk to reasoning, not to text", () => { + const events = translateAll([ + { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "thinking" }, messageId: "t1" }, + ]); + expect(events).toEqual([ + { type: "reasoning", text: "thinking", itemId: "t1", turnId: "turn-1" }, + ]); + }); + + it("drops a user message chunk, because ADE already owns that bubble", () => { + expect( + translateAll([{ sessionUpdate: "user_message_chunk", content: { type: "text", text: "hi" } }]), + ).toEqual([]); + }); + + it("caps nothing, because live IPC publishes the uncompacted envelope", () => { + const long = "x".repeat(200_000); + const events = translateAll([ + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: long }, messageId: "m1" }, + ]); + expect(events[0]).toMatchObject({ type: "text", text: long }); + }); + + it("maps a plan update to plan steps", () => { + const events = translateAll([ + { + sessionUpdate: "plan", + entries: [ + { content: "step one", priority: "high", status: "completed" }, + { content: "step two", priority: "low", status: "in_progress" }, + ], + }, + ]); + expect(events[0]).toMatchObject({ + type: "plan", + steps: [ + { text: "step one", status: "completed" }, + { text: "step two", status: "in_progress" }, + ], + }); + }); +}); + +describe("tool call translation", () => { + it("renders an execute tool as a command row, not a tool row", () => { + const translator = createAcpEventTranslator(); + translator.beginTurn("turn-1"); + const opened = translator.translate({ + sessionUpdate: "tool_call", + toolCallId: "tc1", + title: "Run tests", + kind: "execute", + status: "in_progress", + rawInput: { command: "npm test", cwd: "/lane" }, + }); + expect(translator.rowKindFor("tc1")).toBe("command"); + expect(opened[0]).toMatchObject({ + type: "command", + command: "npm test", + cwd: "/lane", + itemId: "tc1", + status: "running", + }); + + const closed = translator.translate({ + sessionUpdate: "tool_call_update", + toolCallId: "tc1", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "12 passed" } }], + }); + const command = closed.find((event) => event.type === "command"); + expect(command).toMatchObject({ output: "12 passed", status: "completed" }); + // The same work must not also produce a tool_call row. + expect(closed.some((event) => event.type === "tool_call")).toBe(false); + }); + + it("renders an edit tool as file_change rows with diff content", () => { + const translator = createAcpEventTranslator(); + translator.beginTurn("turn-1"); + translator.translate({ + sessionUpdate: "tool_call", + toolCallId: "tc2", + title: "Edit file", + kind: "edit", + status: "in_progress", + }); + const events = translator.translate({ + sessionUpdate: "tool_call_update", + toolCallId: "tc2", + status: "completed", + content: [{ type: "diff", path: "src/a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\n" }], + }); + const change = events.find((event) => event.type === "file_change"); + expect(change).toMatchObject({ type: "file_change", path: "src/a.ts", kind: "modify" }); + expect(change && "diff" in change ? change.diff : "").toContain("+TWO"); + expect(change && "diff" in change ? change.diff : "").toContain("-two"); + }); + + it("keeps one row id per edited path across updates", () => { + const translator = createAcpEventTranslator(); + translator.beginTurn("turn-1"); + translator.translate({ sessionUpdate: "tool_call", toolCallId: "tc3", title: "Edit", kind: "edit" }); + const first = translator.translate({ + sessionUpdate: "tool_call_update", + toolCallId: "tc3", + content: [{ type: "diff", path: "a.ts", oldText: "a", newText: "b" }], + }); + const second = translator.translate({ + sessionUpdate: "tool_call_update", + toolCallId: "tc3", + status: "completed", + content: [{ type: "diff", path: "a.ts", oldText: "a", newText: "c" }], + }); + const firstId = first.find((event) => event.type === "file_change"); + const secondId = second.find((event) => event.type === "file_change"); + expect(firstId && "itemId" in firstId ? firstId.itemId : null).toBe( + secondId && "itemId" in secondId ? secondId.itemId : undefined, + ); + }); + + it("emits tool_call then tool_result for an ordinary tool", () => { + const translator = createAcpEventTranslator(); + translator.beginTurn("turn-1"); + const opened = translator.translate({ + sessionUpdate: "tool_call", + toolCallId: "tc4", + title: "Search", + name: "grep", + kind: "search", + rawInput: { pattern: "todo" }, + }); + expect(opened[0]).toMatchObject({ type: "tool_call", tool: "grep", itemId: "tc4" }); + const closed = translator.translate({ + sessionUpdate: "tool_call_update", + toolCallId: "tc4", + status: "completed", + rawOutput: { matches: 3 }, + }); + expect(closed[0]).toMatchObject({ type: "tool_result", tool: "grep", status: "completed" }); + }); + + it("adopts a tool_call_update for a call it never saw open", () => { + const translator = createAcpEventTranslator(); + translator.beginTurn("turn-1"); + const events = translator.translate({ + sessionUpdate: "tool_call_update", + toolCallId: "orphan", + title: "Orphan", + status: "completed", + }); + expect(events.some((event) => event.type === "tool_call")).toBe(true); + expect(events.some((event) => event.type === "tool_result")).toBe(true); + }); + + it("closes a tool row exactly once", () => { + const translator = createAcpEventTranslator(); + translator.beginTurn("turn-1"); + translator.translate({ sessionUpdate: "tool_call", toolCallId: "tc5", title: "T", kind: "other" }); + const first = translator.translate({ + sessionUpdate: "tool_call_update", + toolCallId: "tc5", + status: "completed", + }); + const second = translator.translate({ + sessionUpdate: "tool_call_update", + toolCallId: "tc5", + status: "completed", + }); + expect(first.filter((event) => event.type === "tool_result")).toHaveLength(1); + expect(second.filter((event) => event.type === "tool_result")).toHaveLength(0); + }); +}); + +describe("unified diff", () => { + it("produces one hunk around the change", () => { + const diff = buildUnifiedDiff("f.ts", "a\nb\nc\n", "a\nB\nc\n"); + expect(diff).toContain("--- a/f.ts"); + expect(diff).toContain("+++ b/f.ts"); + expect(diff).toContain("-b"); + expect(diff).toContain("+B"); + expect(diff).not.toContain("-a"); + }); + + it("returns nothing when the text did not change", () => { + expect(buildUnifiedDiff("f.ts", "same", "same")).toBe(""); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Slash commands and config options +// ───────────────────────────────────────────────────────────────────────────── + +describe("slash command advertisement", () => { + it("fires once for a repeated identical list", () => { + const seen: AcpSlashCommand[][] = []; + const translator = createAcpEventTranslator({ callbacks: { onSlashCommands: (list) => seen.push(list) } }); + const update: AcpSessionUpdate = { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "review", description: "Review the diff" }], + }; + translator.translate(update); + translator.translate(update); + translator.translate(update); + expect(seen).toHaveLength(1); + }); + + it("fires again when the list actually changes", () => { + const seen: AcpSlashCommand[][] = []; + const translator = createAcpEventTranslator({ callbacks: { onSlashCommands: (list) => seen.push(list) } }); + translator.translate({ + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "a", description: "A" }], + }); + translator.translate({ + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "a", description: "A" }, { name: "b", description: "B" }], + }); + expect(seen).toHaveLength(2); + expect(seen[1]).toHaveLength(2); + }); + + it("filters copilot terminal-only commands out of the picker", () => { + expect(includeCopilotSlashCommand({ name: "/diff", description: "" })).toBe(false); + expect(includeCopilotSlashCommand({ name: "resume", description: "" })).toBe(false); + expect(includeCopilotSlashCommand({ name: "login", description: "" })).toBe(false); + expect(includeCopilotSlashCommand({ name: "undo", description: "" })).toBe(false); + expect(includeCopilotSlashCommand({ name: "plan", description: "" })).toBe(true); + }); + + it("applies the dialect filter through the translator", () => { + const seen: AcpSlashCommand[][] = []; + const translator = createAcpEventTranslator({ + includeSlashCommand: copilotDialect.includeSlashCommand, + callbacks: { onSlashCommands: (list) => seen.push(list) }, + }); + translator.translate({ + sessionUpdate: "available_commands_update", + availableCommands: [ + { name: "diff", description: "terminal only" }, + { name: "explain", description: "a real command" }, + ], + }); + expect(seen[0]?.map((command) => command.name)).toEqual(["explain"]); + }); + + it("reports config options and mode changes through the typed callback", () => { + const snapshots: Array<{ currentModeId: string | null; count: number }> = []; + const translator = createAcpEventTranslator({ + callbacks: { + onConfigOptions: (snapshot) => + snapshots.push({ currentModeId: snapshot.currentModeId, count: snapshot.options.length }), + }, + }); + translator.translate({ + sessionUpdate: "config_option_update", + configOptions: [{ id: "mode", name: "Mode" }], + }); + translator.translate({ sessionUpdate: "current_mode_update", currentModeId: "plan" }); + expect(snapshots).toEqual([ + { currentModeId: null, count: 1 }, + { currentModeId: "plan", count: 0 }, + ]); + }); + + it("normalizes Copilot currentValue config options before the callback", () => { + const snapshots: AcpSessionConfigOption[][] = []; + const translator = createAcpEventTranslator({ + callbacks: { onConfigOptions: (snapshot) => snapshots.push(snapshot.options) }, + }); + translator.translate({ + sessionUpdate: "config_option_update", + configOptions: [ + { + id: "mode", + name: "Mode", + currentValue: "https://agentclientprotocol.com/protocol/session-modes#agent", + options: [{ value: "https://agentclientprotocol.com/protocol/session-modes#agent", name: "Agent" }], + } as never, + ], + }); + expect(snapshots[0]?.[0]).toMatchObject({ + id: "mode", + value: "https://agentclientprotocol.com/protocol/session-modes#agent", + options: [{ id: "https://agentclientprotocol.com/protocol/session-modes#agent", name: "Agent" }], + }); + }); +}); + +describe("session config", () => { + it("qwen sets mode, model, and thinking", async () => { + const harness = makeHarness(qwenDialect); + harness.agent.on(ACP_METHOD.sessionSetConfigOption, () => ({ result: {} })); + const session = await withDeadline("open", harness.open()); + await withDeadline("set", session.setConfigOption({ configId: "model", value: "qwen3-coder" })); + const call = harness.agent.received.find((entry) => entry.method === ACP_METHOD.sessionSetConfigOption); + expect(call?.params).toMatchObject({ sessionId: "session-1", configId: "model", value: "qwen3-coder" }); + expect([...qwenDialect.configOptionIds]).toEqual(["mode", "model", "thinking"]); + }); + + it.each(["kimi", "grok", "copilot"] as const)( + "%s refuses a config option instead of sending a call it does not support", + async (providerId) => { + const harness = makeHarness(acpDialectFor(providerId)); + const session = await withDeadline("open", harness.open()); + await withDeadline( + "refusal", + expect(session.setConfigOption({ configId: "mode", value: "plan" })).rejects.toThrow( + /does not accept session config options/i, + ), + ); + expect(harness.agent.methodsReceived()).not.toContain(ACP_METHOD.sessionSetConfigOption); + }, + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Permissions +// ───────────────────────────────────────────────────────────────────────────── + +describe("permission option normalization", () => { + it("trusts the wire kind when the agent sends one", () => { + const option = normalizePermissionOption({ optionId: "x", name: "Weird", kind: "reject_always" }); + expect(option).toMatchObject({ kind: "reject_always", kindFromWire: true }); + }); + + it("recognizes the grok enable-always-approve option from its id", () => { + const option = normalizePermissionOption({ optionId: "enable-always-approve", name: "Always allow" }); + expect(option.kind).toBe("allow_always"); + expect(option.kindFromWire).toBe(false); + }); + + it("classifies plain allow and reject ids", () => { + expect(normalizePermissionOption({ optionId: "allow", name: "Allow" }).kind).toBe("allow_once"); + expect(normalizePermissionOption({ optionId: "reject", name: "Reject" }).kind).toBe("reject_once"); + expect(normalizePermissionOption({ optionId: "deny-always", name: "Never" }).kind).toBe("reject_always"); + }); + + it("never guesses an unknown option into an always kind", () => { + expect(normalizePermissionOption({ optionId: "zzz", name: "Hmm" }).kind).toBe("allow_once"); + }); +}); + +describe("permission round trip", () => { + it("answers the agent with the option the user chose", async () => { + const harness = makeHarness(qwenDialect); + const session = await withDeadline("open", harness.open()); + const answer = harness.agent.callClient(ACP_METHOD.sessionRequestPermission, { + sessionId: session.sessionId, + toolCall: { toolCallId: "tc1", title: "Write file", kind: "edit" }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }); + await vi.waitFor(() => expect(harness.permissions).toHaveLength(1)); + harness.permissions[0]!.select("allow"); + await expect(withDeadline("permission answer", answer)).resolves.toMatchObject({ + outcome: { outcome: "selected", optionId: "allow" }, + }); + expect(harness.settled).toEqual([{ requestId: harness.permissions[0]!.requestId, outcome: "selected" }]); + }); + + it("routes pooled permission requests to the matching ACP session", async () => { + const agent = createMockAcpAgent(); + let nextSession = 1; + agent.on(ACP_METHOD.sessionNew, () => ({ result: { sessionId: `session-${nextSession++}` } })); + const pool = createAcpSessionPool(); + openHarnesses.push(() => pool.disposeAll("test teardown")); + const first: AcpPendingPermission[] = []; + const second: AcpPendingPermission[] = []; + const open = (sessionToken: string, permissions: AcpPendingPermission[]) => openAcpSession({ + dialect: kimiDialect, + cwd: "/lane/worktree", + spawnPlan: kimiDialect.buildSpawnPlan({ binaryPath: "/bin/kimi", cwd: "/lane/worktree", baseEnv: {} }), + sessionToken, + pool, + spawnOverride: () => agent.child, + callbacks: { + onEvents: () => undefined, + onPermissionRequested: (pending) => permissions.push(pending), + onPermissionSettled: () => undefined, + }, + }); + const [firstSession, secondSession] = await withDeadline("pooled opens", Promise.all([ + open("chat-first", first), + open("chat-second", second), + ])); + + const firstAnswer = agent.callClient(ACP_METHOD.sessionRequestPermission, { + sessionId: firstSession.sessionId, + toolCall: { toolCallId: "first-tool", title: "First write", kind: "edit" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }); + await vi.waitFor(() => expect(first).toHaveLength(1)); + expect(second).toHaveLength(0); + + const secondAnswer = agent.callClient(ACP_METHOD.sessionRequestPermission, { + sessionId: secondSession.sessionId, + toolCall: { toolCallId: "second-tool", title: "Second write", kind: "edit" }, + options: [{ optionId: "reject", name: "Reject", kind: "reject_once" }], + }); + await vi.waitFor(() => expect(second).toHaveLength(1)); + expect(first).toHaveLength(1); + + second[0]!.select("reject"); + first[0]!.select("allow"); + await expect(withDeadline("first permission", firstAnswer)).resolves.toMatchObject({ + outcome: { outcome: "selected", optionId: "allow" }, + }); + await expect(withDeadline("second permission", secondAnswer)).resolves.toMatchObject({ + outcome: { outcome: "selected", optionId: "reject" }, + }); + await firstSession.close("test finished"); + await secondSession.close("test finished"); + }); + + it("answers cancelled for every open request when the turn is cancelled", async () => { + const harness = makeHarness(qwenDialect); + harness.agent.on(ACP_METHOD.sessionCancel, () => ({ result: {} })); + const session = await withDeadline("open", harness.open()); + const answer = harness.agent.callClient(ACP_METHOD.sessionRequestPermission, { + sessionId: session.sessionId, + toolCall: { toolCallId: "tc1", title: "Delete" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }); + await vi.waitFor(() => expect(harness.permissions).toHaveLength(1)); + await withDeadline("cancel", session.cancel("user stopped the turn")); + await expect(withDeadline("permission cancellation", answer)).resolves.toMatchObject({ + outcome: { outcome: "cancelled" }, + }); + }); + + it("fails closed when the host cannot raise a card", async () => { + const bridge = createAcpPermissionBridge({ + callbacks: { + onPermissionRequested: () => { + throw new Error("no surface available"); + }, + onPermissionSettled: () => undefined, + }, + }); + await expect( + withDeadline( + "fail closed", + bridge.handleRequest({ + sessionId: "s", + toolCall: { toolCallId: "t" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }), + ), + ).resolves.toMatchObject({ outcome: { outcome: "cancelled" } }); + }); + + it("answers a malformed permission request instead of hanging", async () => { + const bridge = createAcpPermissionBridge({ + callbacks: { onPermissionRequested: () => undefined, onPermissionSettled: () => undefined }, + }); + await expect(withDeadline("malformed", bridge.handleRequest({ nonsense: true }))).resolves.toMatchObject({ + outcome: { outcome: "cancelled" }, + }); + }); + + it("rejects open requests when the connection goes away", async () => { + const settled: string[] = []; + const bridge = createAcpPermissionBridge({ + callbacks: { + onPermissionRequested: () => undefined, + onPermissionSettled: (_id, outcome) => settled.push(outcome), + }, + }); + const pending = bridge.handleRequest({ + sessionId: "s", + toolCall: { toolCallId: "t" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }); + bridge.rejectAll("process exited"); + await withDeadline("rejection", expect(pending).rejects.toThrow(/abandoned/i)); + expect(settled).toEqual(["closed"]); + }); + + it("shapes a pending permission as an ADE pending-input request", () => { + const bridge = createAcpPermissionBridge({ + callbacks: { + onPermissionRequested: (pending) => { + const request = pendingPermissionToInputRequest({ + pending, + source: "ade", + providerLabel: "Grok", + }); + expect(request.kind).toBe("approval"); + expect(request.blocking).toBe(true); + expect(request.options?.map((option) => option.value)).toEqual([ + "enable-always-approve", + "reject", + ]); + expect(request.questions[0]?.question).toBe("Run rm -rf"); + }, + onPermissionSettled: () => undefined, + }, + }); + void bridge.handleRequest({ + sessionId: "s", + toolCall: { toolCallId: "t", title: "Run rm -rf" }, + options: [ + { optionId: "enable-always-approve", name: "Always allow" }, + { optionId: "reject", name: "Reject" }, + ], + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Supervision invariant +// ───────────────────────────────────────────────────────────────────────────── + +describe("unsupervised session invariant", () => { + /** One turn that writes a file and never asks. */ + const writingTurn = (harness: Harness, kind: "edit" | "execute" | "read" = "edit") => { + harness.agent.on(ACP_METHOD.sessionPrompt, (_params, agent) => { + agent.emitUpdate("session-1", { + sessionUpdate: "tool_call", + toolCallId: `tc-${kind}`, + title: kind === "execute" ? "Run ls" : "Write src/app.ts", + kind, + status: "completed", + rawInput: { command: "ls" }, + }); + return { result: { stopReason: "end_turn" } }; + }); + }; + + const notices = (harness: Harness) => + harness.events.filter((event) => event.type === "system_notice"); + + it("says so once when writes happened with zero permission requests", async () => { + const harness = makeHarness(grokDialect); + writingTurn(harness); + const session = await withDeadline("open", harness.open({ permissionMode: "default" })); + await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + expect(notices(harness)).toHaveLength(1); + expect(notices(harness)[0]).toMatchObject({ + type: "system_notice", + severity: "warning", + message: "Grok changed files here without asking ADE to approve. ADE's approval cards can't gate this chat.", + }); + expect(session.unsupervised).toBe(true); + }); + + it("reports what ADE observed, and never guesses who decided", async () => { + // Grok evaluates per-project remembered approvals before prompt policy, so + // silence can also mean the user granted "always allow" earlier — possibly + // in Grok's own TUI, outside ADE. The headline must stay true under both + // causes, and the detail body names them rather than picking one. + const harness = makeHarness(grokDialect); + writingTurn(harness); + const session = await withDeadline("open", harness.open({ permissionMode: "default" })); + await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + const notice = notices(harness)[0]; + const message = notice?.type === "system_notice" ? notice.message : ""; + expect(message).not.toMatch(/approved its own/i); + expect(message).toContain("without asking ADE to approve"); + const detail = notice?.type === "system_notice" ? notice.detail : undefined; + expect(typeof detail === "string" && detail).toContain("already granted"); + expect(typeof detail === "string" && detail).toContain("is approving the work itself"); + }); + + it("says it once and only once, however many turns follow", async () => { + const harness = makeHarness(grokDialect); + writingTurn(harness); + const session = await withDeadline("open", harness.open({ permissionMode: "default" })); + await withDeadline("turn 1", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + await withDeadline("turn 2", session.prompt({ turnId: "t2", blocks: [textPromptBlock("go")] })); + await withDeadline("turn 3", session.prompt({ turnId: "t3", blocks: [textPromptBlock("go")] })); + expect(notices(harness)).toHaveLength(1); + }); + + it("stays silent when the agent did ask, even for later turns it does not", async () => { + // A user who answered `allow-edits-session` bought the silence. Blaming the + // agent for the user's own choice would be a false alarm. + const harness = makeHarness(grokDialect); + const session = await withDeadline("open", harness.open({ permissionMode: "default" })); + const answer = harness.agent.callClient(ACP_METHOD.sessionRequestPermission, { + sessionId: session.sessionId, + toolCall: { toolCallId: "tc1", title: "Write file", kind: "edit" }, + options: [{ optionId: "allow-edits-session", name: "Allow edits this session" }], + }); + await vi.waitFor(() => expect(harness.permissions).toHaveLength(1)); + harness.permissions[0]!.select("allow-edits-session"); + await withDeadline("permission answer", answer); + + writingTurn(harness); + await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + expect(notices(harness)).toHaveLength(0); + expect(session.unsupervised).toBe(false); + }); + + it("stays silent in a mode that never promised a prompt", async () => { + const harness = makeHarness(grokDialect); + writingTurn(harness); + const session = await withDeadline("open", harness.open({ permissionMode: "yolo" })); + await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + expect(notices(harness)).toHaveLength(0); + }); + + it("stays silent for a read-only turn, because reads never prompt anywhere", async () => { + const harness = makeHarness(grokDialect); + writingTurn(harness, "read"); + const session = await withDeadline("open", harness.open({ permissionMode: "default" })); + await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + expect(notices(harness)).toHaveLength(0); + }); + + it("names commands rather than file changes when the turn only ran commands", async () => { + const harness = makeHarness(grokDialect); + writingTurn(harness, "execute"); + const session = await withDeadline("open", harness.open({ permissionMode: "default" })); + await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + expect(notices(harness)[0]).toMatchObject({ + message: "Grok ran commands here without asking ADE to approve. ADE's approval cards can't gate this chat.", + }); + }); + + it("degrades loudly when the preflight could not confirm supervision", async () => { + // No tool call at all: a session ADE could not verify still says so, and it + // still runs. It never claims a supervision it cannot deliver. + const harness = makeHarness(grokDialect); + harness.agent.on(ACP_METHOD.sessionPrompt, () => ({ result: { stopReason: "end_turn" } })); + const session = await withDeadline( + "open", + harness.open({ permissionMode: "default", supervisionPreflight: { ok: false, detail: "spawn ENOENT" } }), + ); + await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + expect(notices(harness)).toHaveLength(1); + expect(notices(harness)[0]).toMatchObject({ + message: "ADE could not confirm that Grok will ask before it edits files here. It may approve its own changes.", + }); + await withDeadline("turn 2", session.prompt({ turnId: "t2", blocks: [textPromptBlock("go")] })); + expect(notices(harness)).toHaveLength(1); + }); + + it("says nothing at all when the preflight passed and the agent behaved", async () => { + const harness = makeHarness(grokDialect); + harness.agent.on(ACP_METHOD.sessionPrompt, () => ({ result: { stopReason: "end_turn" } })); + const session = await withDeadline( + "open", + harness.open({ permissionMode: "default", supervisionPreflight: { ok: true } }), + ); + await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + expect(notices(harness)).toHaveLength(0); + }); + + it("repeats nothing for a chat that already showed the line in an earlier run", async () => { + const harness = makeHarness(grokDialect); + writingTurn(harness); + const session = await withDeadline( + "open", + harness.open({ permissionMode: "default", supervisionAlreadyNotified: true }), + ); + await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + expect(notices(harness)).toHaveLength(0); + }); + + it("applies to every ACP provider, not just grok", async () => { + for (const providerId of ACP_PROVIDER_IDS) { + const harness = makeHarness(acpDialectFor(providerId)); + writingTurn(harness); + const session = await withDeadline("open", harness.open({ permissionMode: "default" })); + await withDeadline("turn", session.prompt({ turnId: "t1", blocks: [textPromptBlock("go")] })); + expect(notices(harness)).toHaveLength(1); + expect(notices(harness)[0]?.type === "system_notice" && notices(harness)[0]?.message) + .toContain(acpDialectFor(providerId).displayName); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Cancel +// ───────────────────────────────────────────────────────────────────────────── + +describe("cancel", () => { + it.each(["grok", "copilot"] as const)( + "%s sends cancel as a notification, never as a request", + async (providerId) => { + const harness = makeHarness(acpDialectFor(providerId)); + const session = await withDeadline("open", harness.open()); + await withDeadline("cancel", session.cancel("stopped")); + const cancel = harness.agent.received.find((entry) => entry.method === ACP_METHOD.sessionCancel); + expect(cancel?.isNotification).toBe(true); + }, + ); + + it.each(["qwen", "kimi"] as const)("%s sends cancel as a request", async (providerId) => { + const harness = makeHarness(acpDialectFor(providerId)); + harness.agent.on(ACP_METHOD.sessionCancel, () => ({ result: {} })); + const session = await withDeadline("open", harness.open()); + await withDeadline("cancel", session.cancel("stopped")); + const cancel = harness.agent.received.find((entry) => entry.method === ACP_METHOD.sessionCancel); + expect(cancel?.isNotification).toBe(false); + }); + + it("falls back to the notification form when the request form is unknown", async () => { + const harness = makeHarness(qwenDialect); + // No handler registered, so the mock answers -32601, exactly like Grok. + const session = await withDeadline("open", harness.open()); + await withDeadline("cancel", session.cancel("stopped")); + const cancels = harness.agent.received.filter((entry) => entry.method === ACP_METHOD.sessionCancel); + expect(cancels).toHaveLength(2); + expect(cancels[1]?.isNotification).toBe(true); + }); + + it("reports a cancelled turn as interrupted even when the agent says end_turn", async () => { + const harness = makeHarness(copilotDialect); + let releasePrompt: (() => void) | null = null; + harness.agent.on(ACP_METHOD.sessionPrompt, async () => { + await new Promise((resolve) => { + releasePrompt = resolve; + }); + // github/copilot-cli issue 4561: a cancelled turn reports end_turn. + return { result: { stopReason: "end_turn" } }; + }); + const session = await withDeadline("open", harness.open()); + const turn = session.prompt({ turnId: "turn-1", blocks: [textPromptBlock("go")] as AcpContentBlock[] }); + await vi.waitFor(() => expect(releasePrompt).toBeTruthy()); + await withDeadline("cancel", session.cancel("user stopped")); + releasePrompt!(); + const outcome = await withDeadline("turn", turn); + expect(outcome.stopReason).toBe("end_turn"); + expect(outcome.interrupted).toBe(true); + }); + + it("reports an uncancelled turn as not interrupted", async () => { + const harness = makeHarness(qwenDialect); + harness.agent.on(ACP_METHOD.sessionPrompt, () => ({ result: { stopReason: "end_turn" } })); + const session = await withDeadline("open", harness.open()); + const outcome = await withDeadline( + "turn", + session.prompt({ turnId: "turn-1", blocks: [textPromptBlock("go")] as AcpContentBlock[] }), + ); + expect(outcome.interrupted).toBe(false); + }); +}); + +describe("turn lifecycle", () => { + it("rejects the in-flight prompt when the process exits", async () => { + const harness = makeHarness(qwenDialect); + harness.agent.on(ACP_METHOD.sessionPrompt, () => new Promise(() => undefined)); + const session = await withDeadline("open", harness.open()); + const turn = session.prompt({ + turnId: "turn-1", + blocks: [textPromptBlock("go")] as AcpContentBlock[], + }); + await harness.agent.waitForMethod(ACP_METHOD.sessionPrompt); + harness.agent.exit(1); + await expect(withDeadline("prompt after exit", turn)).rejects.toThrow(/closed/i); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Usage +// ───────────────────────────────────────────────────────────────────────────── + +describe("usage", () => { + it("qwen folds a usage_update into a context_usage event", async () => { + const harness = makeHarness(qwenDialect); + harness.agent.on(ACP_METHOD.sessionPrompt, (_params, agent) => { + agent.emitUpdate("session-1", { sessionUpdate: "usage_update", used: 25_000, size: 100_000 }); + return { result: { stopReason: "end_turn" } }; + }); + const session = await withDeadline("open", harness.open()); + await withDeadline( + "turn", + session.prompt({ turnId: "turn-1", blocks: [textPromptBlock("go")] as AcpContentBlock[] }), + ); + const usage = harness.events.find((event) => event.type === "context_usage"); + expect(usage).toMatchObject({ + type: "context_usage", + usage: { totalTokens: 25_000, maxTokens: 100_000, percentage: 25 }, + }); + }); + + it("grok reads usage from the prompt result meta", async () => { + const harness = makeHarness(grokDialect); + harness.agent.on(ACP_METHOD.sessionPrompt, () => ({ + result: { + stopReason: "end_turn", + _meta: { + costUsdTicks: 2_500_000_000, + cachedReadTokens: 40, + modelUsage: { "grok-4": { inputTokens: 100, outputTokens: 20, reasoningTokens: 5 } }, + }, + }, + })); + const session = await withDeadline("open", harness.open()); + const outcome = await withDeadline( + "turn", + session.prompt({ turnId: "turn-1", blocks: [textPromptBlock("go")] as AcpContentBlock[] }), + ); + expect(outcome.usage).toMatchObject({ + costUsd: 2.5, + cacheReadTokens: 40, + inputTokens: 100, + outputTokens: 20, + reasoningTokens: 5, + totalTokens: 120, + }); + expect(outcome.events.some((event) => event.type === "tokens")).toBe(true); + }); + + it("grok usage reader returns null for meta it cannot read", () => { + expect(readGrokPromptUsage(null)).toBeNull(); + expect(readGrokPromptUsage({ unrelated: true })).toBeNull(); + }); + + it("kimi reports no usage, and emits no usage events at all", async () => { + const harness = makeHarness(kimiDialect); + harness.agent.on(ACP_METHOD.sessionPrompt, (_params, agent) => { + // Even if a future build starts sending it, the dialect declares none, so + // ADE must stay consistent with the hidden usage meter. + agent.emitUpdate("session-1", { sessionUpdate: "usage_update", used: 10, size: 100 }); + return { result: { stopReason: "end_turn", usage: { totalTokens: 5, inputTokens: 4, outputTokens: 1 } } }; + }); + const session = await withDeadline("open", harness.open()); + const outcome = await withDeadline( + "turn", + session.prompt({ turnId: "turn-1", blocks: [textPromptBlock("go")] as AcpContentBlock[] }), + ); + expect(outcome.usage).toBeNull(); + expect(outcome.events).toEqual([]); + expect(harness.events.some((event) => event.type === "context_usage")).toBe(false); + }); + + it("copilot reads usage from both sources", async () => { + const behavior = behaviorOf(copilotDialect.usage); + expect(behavior).toBeTruthy(); + expect(behavior!({ usageUpdate: { used: 10, size: 40 } })).toMatchObject({ + contextUsedTokens: 10, + contextWindowTokens: 40, + }); + expect(behavior!({ promptUsage: { inputTokens: 7, outputTokens: 3, totalTokens: 10, thoughtTokens: 2 } })).toMatchObject({ + inputTokens: 7, + outputTokens: 3, + totalTokens: 10, + reasoningTokens: 2, + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Close and pooling +// ───────────────────────────────────────────────────────────────────────────── + +describe("close and eviction", () => { + it.each(["kimi", "grok", "copilot"] as const)( + "%s ends a session with session/close and keeps the process", + async (providerId) => { + const harness = makeHarness(acpDialectFor(providerId)); + harness.agent.on(ACP_METHOD.sessionClose, () => ({ result: {} })); + const session = await withDeadline("open", harness.open()); + await withDeadline("close", session.close("chat ended")); + expect(harness.agent.methodsReceived()).toContain(ACP_METHOD.sessionClose); + expect(session.connection.isAlive()).toBe(true); + }, + ); + + it("qwen ends a session by ending its private process", async () => { + const harness = makeHarness(qwenDialect); + const session = await withDeadline("open", harness.open()); + await withDeadline("close", session.close("chat ended")); + expect(harness.agent.methodsReceived()).not.toContain(ACP_METHOD.sessionClose); + expect(session.connection.isAlive()).toBe(false); + }); + + it("close is safe to call twice", async () => { + const harness = makeHarness(kimiDialect); + harness.agent.on(ACP_METHOD.sessionClose, () => ({ result: {} })); + const session = await withDeadline("open", harness.open()); + await withDeadline("close", session.close("first")); + await withDeadline("close again", session.close("second")); + expect(harness.agent.methodsReceived().filter((method) => method === ACP_METHOD.sessionClose)).toHaveLength(1); + }); + + it("survives an agent that answers session/close with method not found", async () => { + const harness = makeHarness(copilotDialect); + const session = await withDeadline("open", harness.open()); + await withDeadline("close", session.close("chat ended")); + // Copilot 1.0.82 answers -32601. Degraded, not thrown. The pooled process + // stays usable for other chats. + expect(session.connection.isAlive()).toBe(true); + }); +}); + +describe("pooling", () => { + it("keys a pool entry on provider, cwd, environment, and invocation", () => { + const base = { providerId: "qwen", cwd: "/lane", envHash: "aaa", invocationHash: "iii" }; + const keys = [ + buildAcpPoolKey(base), + buildAcpPoolKey({ ...base, envHash: "bbb" }), + buildAcpPoolKey({ ...base, cwd: "/other" }), + buildAcpPoolKey({ ...base, invocationHash: "jjj" }), + ]; + expect(new Set(keys).size).toBe(4); + }); + + it("separates two chats whose model rides a process-global spawn flag", () => { + // Grok takes the model as `-m` on the command line, and `session/new` + // cannot override it. Sharing would run the second chat on the first + // chat's model. + const forModel = (modelId: string) => + hashSpawnInvocation( + grokDialect.buildSpawnPlan({ binaryPath: "/bin/grok", cwd: "/lane", baseEnv: {}, modelId }), + ); + expect(forModel("grok-4")).not.toBe(forModel("grok-4-fast")); + }); + + it("hashes only the declared environment keys", () => { + const base = hashPoolEnv({ QWEN_HOME: "/h", IRRELEVANT: "1" }, ["QWEN_HOME"]); + const same = hashPoolEnv({ QWEN_HOME: "/h", IRRELEVANT: "2" }, ["QWEN_HOME"]); + const different = hashPoolEnv({ QWEN_HOME: "/other" }, ["QWEN_HOME"]); + expect(base).toBe(same); + expect(base).not.toBe(different); + }); + + it("gives a kill_process dialect a private key per session", () => { + const base = { providerId: "qwen", cwd: "/lane", envHash: "a", invocationHash: "i" }; + expect(buildAcpPoolKey({ ...base, privateToken: "chat-1" })).not.toBe( + buildAcpPoolKey({ ...base, privateToken: "chat-2" }), + ); + }); + + it("shares one process between two sessions with the same key", async () => { + const pool = createAcpSessionPool(); + const agent = createMockAcpAgent(); + const plan = grokDialect.buildSpawnPlan({ binaryPath: "/bin/grok", cwd: "/lane", baseEnv: {} }); + const first = await withDeadline( + "acquire", + pool.acquire({ + dialect: grokDialect, + spawnPlan: plan, + poolEnvKeys: grokDialect.poolEnvKeys, + sessionToken: "chat-1", + spawnOverride: () => agent.child, + }), + ); + const second = await withDeadline( + "acquire again", + pool.acquire({ + dialect: grokDialect, + spawnPlan: plan, + poolEnvKeys: grokDialect.poolEnvKeys, + sessionToken: "chat-2", + spawnOverride: () => agent.child, + }), + ); + expect(second.connection).toBe(first.connection); + expect(pool.size()).toBe(1); + pool.disposeAll("test finished"); + }); + + it("never shares a qwen process between two sessions", async () => { + const pool = createAcpSessionPool(); + const plan = qwenDialect.buildSpawnPlan({ binaryPath: "/bin/qwen", cwd: "/lane", baseEnv: {} }); + const agents = [createMockAcpAgent(), createMockAcpAgent()]; + let index = 0; + const acquire = (sessionToken: string) => + pool.acquire({ + dialect: qwenDialect, + spawnPlan: plan, + poolEnvKeys: qwenDialect.poolEnvKeys, + sessionToken, + spawnOverride: () => agents[index++]!.child, + }); + const first = await withDeadline("acquire", acquire("chat-1")); + const second = await withDeadline("acquire again", acquire("chat-2")); + expect(second.connection).not.toBe(first.connection); + expect(pool.size()).toBe(2); + pool.disposeAll("test finished"); + }); + + it("keeps a released connection warm for the idle window", async () => { + vi.useFakeTimers(); + const pool = createAcpSessionPool(); + const agent = createMockAcpAgent(); + const plan = grokDialect.buildSpawnPlan({ binaryPath: "/bin/grok", cwd: "/lane", baseEnv: {} }); + const acquired = pool.acquire({ + dialect: grokDialect, + spawnPlan: plan, + poolEnvKeys: grokDialect.poolEnvKeys, + sessionToken: "chat-1", + idleTtlMs: 5_000, + spawnOverride: () => agent.child, + }); + await vi.advanceTimersByTimeAsync(0); + const lease = await acquired; + lease.release(); + expect(pool.has(lease.poolKey)).toBe(true); + await vi.advanceTimersByTimeAsync(4_999); + expect(pool.has(lease.poolKey)).toBe(true); + await vi.advanceTimersByTimeAsync(2); + expect(pool.has(lease.poolKey)).toBe(false); + vi.useRealTimers(); + }); + + it("ignores a release from a stale generation", async () => { + const pool = createAcpSessionPool(); + const plan = grokDialect.buildSpawnPlan({ binaryPath: "/bin/grok", cwd: "/lane", baseEnv: {} }); + const agents = [createMockAcpAgent(), createMockAcpAgent()]; + let index = 0; + const acquire = () => + pool.acquire({ + dialect: grokDialect, + spawnPlan: plan, + poolEnvKeys: grokDialect.poolEnvKeys, + sessionToken: "chat-1", + spawnOverride: () => agents[index++]!.child, + }); + const first = await withDeadline("acquire", acquire()); + first.evict("simulated crash"); + const second = await withDeadline("acquire again", acquire()); + expect(second.generation).not.toBe(first.generation); + // The stale lease must not tear down the replacement. + first.release(); + expect(pool.has(second.poolKey)).toBe(true); + pool.disposeAll("test finished"); + }); + + it("drops a pool entry when its process exits", async () => { + const pool = createAcpSessionPool(); + const agent = createMockAcpAgent(); + const plan = grokDialect.buildSpawnPlan({ binaryPath: "/bin/grok", cwd: "/lane", baseEnv: {} }); + const lease = await withDeadline( + "acquire", + pool.acquire({ + dialect: grokDialect, + spawnPlan: plan, + poolEnvKeys: grokDialect.poolEnvKeys, + sessionToken: "chat-1", + spawnOverride: () => agent.child, + }), + ); + agent.exit(3); + await vi.waitFor(() => expect(pool.has(lease.poolKey)).toBe(false)); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// MCP injection +// ───────────────────────────────────────────────────────────────────────────── + +describe("MCP injection", () => { + it("drops an HTTP server when the agent did not advertise HTTP", async () => { + const harness = makeHarness(qwenDialect, { + agentCapabilities: { loadSession: true, mcpCapabilities: { http: false, sse: false } }, + }); + await withDeadline( + "open", + harness.open({ + mcpServers: [ + { type: "http", name: "remote", url: "https://example.test", headers: [] }, + { name: "local", command: "/bin/server", args: [], env: [] }, + ], + }), + ); + const call = harness.agent.received.find((entry) => entry.method === ACP_METHOD.sessionNew); + const servers = (call?.params as { mcpServers: Array<{ name: string }> }).mcpServers; + expect(servers.map((server) => server.name)).toEqual(["local"]); + }); + + it("keeps an HTTP server when the agent advertised HTTP", async () => { + const harness = makeHarness(qwenDialect, { + agentCapabilities: { loadSession: true, mcpCapabilities: { http: true, sse: true } }, + }); + await withDeadline( + "open", + harness.open({ mcpServers: [{ type: "http", name: "remote", url: "https://example.test", headers: [] }] }), + ); + const call = harness.agent.received.find((entry) => entry.method === ACP_METHOD.sessionNew); + expect((call?.params as { mcpServers: unknown[] }).mcpServers).toHaveLength(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// The run | degrade matrix +// ───────────────────────────────────────────────────────────────────────────── + +describe("run | degrade conformance matrix", () => { + type Feature = + | "capabilities" + | "lifecycle" + | "prompt_stream" + | "permission" + | "cancel" + | "close_eviction" + | "resume" + | "slash_advertise" + | "usage_fold" + | "mcp_injection"; + + const EXPECTED: Record> = { + capabilities: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + lifecycle: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + prompt_stream: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + permission: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + cancel: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + // Qwen 0.22.3 has no session/close. It degrades to ending its private process. + close_eviction: { qwen: "degrade", kimi: "run", grok: "run", copilot: "run" }, + // Copilot's resume is unverified, so ADE uses session/load instead. + resume: { qwen: "run", kimi: "run", grok: "run", copilot: "degrade" }, + slash_advertise: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + // Kimi reports no usage at all. + usage_fold: { qwen: "run", kimi: "degrade", grok: "run", copilot: "run" }, + mcp_injection: { qwen: "run", kimi: "run", grok: "run", copilot: "run" }, + }; + + it("records the expected outcome for every cell", () => { + const cells = Object.values(EXPECTED).flatMap((row) => Object.values(row)); + expect(cells).toHaveLength(40); + }); + + it.each(ACP_PROVIDER_IDS)("%s matches its declared matrix row", (providerId: AcpProviderId) => { + const dialect = acpDialectFor(providerId); + expect(dialect.closeSession.declared ? "run" : "degrade").toBe(EXPECTED.close_eviction[providerId]); + expect(dialect.resumeSession.declared ? "run" : "degrade").toBe(EXPECTED.resume[providerId]); + expect(dialect.usage.declared ? "run" : "degrade").toBe(EXPECTED.usage_fold[providerId]); + expect(dialect.mcpInjection.declared ? "run" : "degrade").toBe(EXPECTED.mcp_injection[providerId]); + }); + + it.each(ACP_PROVIDER_IDS)( + "%s runs a whole turn without throwing and without hanging", + async (providerId: AcpProviderId) => { + const harness = makeHarness(acpDialectFor(providerId)); + harness.agent.on(ACP_METHOD.sessionPrompt, (_params, agent) => { + agent.emitUpdate("session-1", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "done" }, + messageId: "m1", + }); + agent.emitUpdate("session-1", { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "explain", description: "Explain the change" }], + }); + return { result: { stopReason: "end_turn" } }; + }); + harness.agent.on(ACP_METHOD.sessionCancel, () => ({ result: {} })); + harness.agent.on(ACP_METHOD.sessionClose, () => ({ result: {} })); + + const session = await withDeadline("open", harness.open()); + const outcome = await withDeadline( + "turn", + session.prompt({ turnId: "turn-1", blocks: [textPromptBlock("hello")] as AcpContentBlock[] }), + ); + expect(outcome.interrupted).toBe(false); + await vi.waitFor(() => + expect(harness.events.some((event) => event.type === "text" && event.text === "done")).toBe(true), + ); + expect(harness.slashLists).toHaveLength(1); + await withDeadline("close", session.close("chat ended")); + }, + ); + + it.each(ACP_PROVIDER_IDS)( + "%s degrades rather than hangs when the agent implements nothing but initialize", + async (providerId: AcpProviderId) => { + const dialect = acpDialectFor(providerId); + const agent = createMockAcpAgent(); + // Deliberately no session/new handler, so it answers -32601. + const pool = createAcpSessionPool(); + const attempt = openAcpSession({ + dialect, + cwd: "/lane", + spawnPlan: dialect.buildSpawnPlan({ binaryPath: "/bin/x", cwd: "/lane", baseEnv: {} }), + sessionToken: "chat-1", + pool, + spawnOverride: () => agent.child, + callbacks: { + onEvents: () => undefined, + onPermissionRequested: () => undefined, + onPermissionSettled: () => undefined, + }, + }); + await withDeadline("open failure", expect(attempt).rejects.toBeInstanceOf(AcpRpcError)); + await withDeadline( + "error code", + expect(attempt).rejects.toMatchObject({ code: ACP_RPC_METHOD_NOT_FOUND }), + ); + pool.disposeAll("test finished"); + }, + ); +}); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpHostTypes.ts b/apps/desktop/src/main/services/chat/acpHost/acpHostTypes.ts new file mode 100644 index 0000000000..b264d15e12 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpHostTypes.ts @@ -0,0 +1,382 @@ +/** + * Dialect descriptors for the shared ACP host. + * + * One host speaks the protocol. Each provider supplies a descriptor that names + * its spawn plan, its quirks, and its capabilities. The host never branches on + * a provider id. It reads the descriptor. + * + * ## The requiresBehavior invariant + * + * A capability that a dialect declares must carry the function that performs + * it. The type system enforces this in two ways. + * + * 1. `AcpCapability` is a union. The present branch demands `behavior`. The + * absent branch forbids it. You cannot write `{ declared: true }` alone. + * 2. The style fields that pair with a capability (`usageSource`, `closeStyle`, + * `loadPolicy`) are discriminants of contract unions. A dialect that says + * `closeStyle: "close_request"` must supply `closeSession.behavior`. A + * dialect that says `closeStyle: "kill_process"` must not. + * + * A wrong pairing is a compile error, not a runtime surprise. + */ + +import type { + AcpAvailableCommand, + AcpContentBlock, + AcpMcpServer, + AcpPromptResponse, + AcpPromptUsage, + AcpSessionConfigOption, + AcpSessionId, + AcpUsageUpdate, +} from "./acpProtocolTypes"; +import type { AcpProviderId } from "../../../../shared/acpProviderMetadata"; +export { ACP_PROVIDER_IDS, type AcpProviderId } from "../../../../shared/acpProviderMetadata"; + +/** Settings shows a preview label. Pickers show every provider the same way. */ +export type AcpProviderTier = "first_class" | "preview"; + +// ── Capability declarations ────────────────────────────────────────────────── + +export type AcpAbsentCapability = { readonly declared: false }; +export type AcpPresentCapability = { + readonly declared: true; + readonly behavior: TBehavior; +}; + +/** + * A capability is either absent, or present with the behavior that performs it. + * There is no third shape. + */ +export type AcpCapability = AcpAbsentCapability | AcpPresentCapability; + +/** The single absent value. It is assignable to any `AcpCapability`. */ +export const capabilityAbsent: AcpAbsentCapability = { declared: false }; + +/** Declare a capability together with the behavior that performs it. */ +export function capability(behavior: TBehavior): AcpPresentCapability { + return { declared: true, behavior }; +} + +/** Read the behavior of a capability, or `null` when the dialect omits it. */ +export function behaviorOf(entry: AcpCapability): TBehavior | null { + return entry.declared ? entry.behavior : null; +} + +// ── Style enumerations ─────────────────────────────────────────────────────── + +/** + * How to stop a running turn. + * + * Grok, and Copilot 1.0.82, answer a `session/cancel` REQUEST with -32601. + * They accept the same call as a notification. Qwen and Kimi accept the + * request form. + */ +export type AcpCancelStyle = "request" | "notification"; + +/** + * How to end a session. + * + * `kill_process` means the agent has no `session/close`. Qwen 0.22.3 is in + * that group: it does not advertise close and answers -32601. Each such chat + * owns its own process and the host ends the chat by ending the process. + * Kimi 0.39.1 advertises close and implements it, so it is `close_request`. + */ +export type AcpCloseStyle = "close_request" | "kill_process"; + +/** Where token and context numbers come from, when they come at all. */ +export type AcpUsageSource = "usage_update" | "prompt_result_meta" | "none"; + +/** + * How to rejoin an existing agent session. + * + * `resume_preferred` — try `session/resume` first, then `session/load`. + * `load_only` — only `session/load` exists. + * `never` — start a new agent session every time. + */ +export type AcpLoadPolicy = "resume_preferred" | "load_only" | "never"; + +// ── Behavior signatures ────────────────────────────────────────────────────── + +export type AcpSpawnPlan = { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd: string; +}; + +export type AcpSpawnContext = { + /** Absolute path of the agent binary or shim that the detector resolved. */ + binaryPath: string; + /** Lane worktree. Becomes both the process cwd and the session cwd. */ + cwd: string; + /** Environment to build on. Usually `process.env`. */ + baseEnv: NodeJS.ProcessEnv; + /** Provider-native model token, when the user picked one. */ + modelId?: string | null; + /** Provider-native reasoning effort token, when the provider takes one. */ + reasoningEffort?: string | null; + /** ADE abstract permission mode, already mapped by the caller. */ + permissionMode?: string | null; + /** Config home directory to export, when the provider honors one. */ + configHome?: string | null; +}; + +export type AcpResumeBehavior = (args: { + sessionId: AcpSessionId; + cwd: string; + mcpServers: AcpMcpServer[]; +}) => { method: string; params: Record }; + +export type AcpLoadBehavior = (args: { + sessionId: AcpSessionId; + cwd: string; + mcpServers: AcpMcpServer[]; +}) => { method: string; params: Record }; + +export type AcpCloseBehavior = (args: { + sessionId: AcpSessionId; +}) => { method: string; params: Record }; + +export type AcpSessionConfigBehavior = (args: { + sessionId: AcpSessionId; + configId: string; + value: string | boolean; +}) => { method: string; params: Record }; + +/** + * Normalized usage sample. `null` means the payload carried nothing usable, and + * the host emits no usage event for it. + */ +export type AcpUsageSample = { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; + totalTokens?: number; + /** Context tokens already occupied. */ + contextUsedTokens?: number; + /** Context window size. */ + contextWindowTokens?: number; + costUsd?: number; +}; + +export type AcpUsageBehavior = (input: { + /** Present when the source is `usage_update`. */ + usageUpdate?: AcpUsageUpdate; + /** Present when the source is `prompt_result_meta`. */ + promptResponse?: AcpPromptResponse; + /** Convenience alias of `promptResponse.usage`. */ + promptUsage?: AcpPromptUsage | null; +}) => AcpUsageSample | null; + +/** + * Filter and rewrite the MCP servers ADE would inject. + * + * Return an empty array to inject nothing. The host NEVER offers the + * Codex-signed computer-use server to an ACP provider, so a dialect only sees + * servers that are already safe to consider. + */ +export type AcpMcpInjectionBehavior = (args: { + servers: AcpMcpServer[]; + agentSupportsHttp: boolean; + agentSupportsSse: boolean; +}) => AcpMcpServer[]; + +/** Turn an ADE image attachment into a prompt content block. */ +export type AcpImagePromptBehavior = (args: { + base64Data: string; + mimeType: string; + uri?: string | null; +}) => AcpContentBlock; + +// ── Contract unions ────────────────────────────────────────────────────────── + +/** A usage source other than `none` demands a reader for it. */ +export type AcpUsageContract = + | { readonly usageSource: "none"; readonly usage: AcpAbsentCapability } + | { + readonly usageSource: "usage_update" | "prompt_result_meta"; + readonly usage: AcpPresentCapability; + }; + +/** `close_request` demands a close builder. `kill_process` forbids one. */ +export type AcpCloseContract = + | { + readonly closeStyle: "close_request"; + readonly closeSession: AcpPresentCapability; + } + | { readonly closeStyle: "kill_process"; readonly closeSession: AcpAbsentCapability }; + +/** The load policy decides which of the two rejoin builders must exist. */ +export type AcpLoadContract = + | { + readonly loadPolicy: "never"; + readonly resumeSession: AcpAbsentCapability; + readonly loadSession: AcpAbsentCapability; + } + | { + readonly loadPolicy: "load_only"; + readonly resumeSession: AcpAbsentCapability; + readonly loadSession: AcpPresentCapability; + } + | { + readonly loadPolicy: "resume_preferred"; + readonly resumeSession: AcpPresentCapability; + readonly loadSession: AcpPresentCapability; + }; + +// ── Session id persistence ─────────────────────────────────────────────────── + +export type AcpSessionIdPersistence = { + /** + * True when the launcher can choose the session id. Kimi cannot: the host + * must read the id the agent reports and store it. + */ + assignableAtLaunch: boolean; + /** + * Directory that holds the provider's own session files, relative to the + * config home. W4 uses it for the disk-adopt capture that Kimi needs. + * `null` means the provider reports its id on the wire and needs no capture. + */ + sessionsDirName: string | null; + /** Shape of the ids the provider mints. Diagnostics only. */ + idShape: "uuid" | "ulid" | "opaque"; +}; + +// ── Outbound setup notifications ───────────────────────────────────────────── + +export type AcpOutboundNotification = { method: string; params: Record }; + +// ── Auth probe ─────────────────────────────────────────────────────────────── + +export type AcpAuthProbe = { + /** + * `authenticate` method id to send. `null` means "use the first method the + * agent advertised in its `initialize` response". + */ + methodId: string | null; + /** Command to print when the probe fails. */ + loginCommand: string; + /** Environment keys that also authenticate the provider. */ + apiKeyEnvVars: readonly string[]; +}; + +// ── The descriptor ─────────────────────────────────────────────────────────── + +export type AcpDialectBase = { + readonly providerId: AcpProviderId; + readonly displayName: string; + readonly tier: AcpProviderTier; + + /** Executable names to look for, in order of preference. */ + readonly binaryNames: readonly string[]; + + /** Build the process spawn plan. Pure: no file system reads, no spawns. */ + readonly buildSpawnPlan: (context: AcpSpawnContext) => AcpSpawnPlan; + + readonly cancelStyle: AcpCancelStyle; + + /** + * Environment keys that must match before two chats share one process. Add a + * key here only when a different value changes how the agent behaves. Adding + * a per-chat key would defeat pooling completely. + */ + readonly poolEnvKeys: readonly string[]; + + /** + * True when a session may not share a process with another session. The host + * gives such a dialect a private pool key, so eviction never crosses chats. + */ + readonly oneProcessPerSession: boolean; + + /** + * Never advertise `fs` unless this is true. Grok proxies binary reads through + * the text file system and corrupts assets, so it stays false there. It is + * false for every dialect today; the flag exists so a future dialect can opt + * in explicitly rather than by omission. + */ + readonly advertiseFsCapability: boolean; + + /** Advertise the `terminal` client capability at `initialize`. */ + readonly advertiseTerminalCapability: boolean; + + /** Extra `_meta` to stamp on the `initialize` request. */ + readonly initializeMeta: Readonly> | null; + + /** Identity ADE reports as the client. */ + readonly clientInfo: { name: string; title: string; version: string }; + + /** + * Notifications to send right after `session/new` succeeds. Grok needs one + * here to switch off the auto-approve mode it reads from the user's Claude + * settings file. + */ + readonly postSessionNewNotifications: (args: { + sessionId: AcpSessionId; + }) => AcpOutboundNotification[]; + + /** + * Return true to show a slash command in ADE's picker. Some agents advertise + * commands that only their own terminal UI can run. Those commands reach the + * model as plain text if a user picks them, so they are filtered out here. + */ + readonly includeSlashCommand: (command: AcpAvailableCommand) => boolean; + + /** + * Extension notifications the host must receive and ignore. Grok sends a + * spinner hint that looks like a permission request. Naming it here keeps it + * out of the "unhandled notification" log and out of the permission bridge. + */ + readonly ignoredNotificationMethods: readonly string[]; + + readonly sessionIdPersistence: AcpSessionIdPersistence; + + readonly authProbe: AcpAuthProbe; + + /** + * One line per known hole, for the first-use degradation note. Keep each line + * short, factual, and about behavior the user can see. + */ + readonly degradationNotes: readonly string[]; + + /** Optional capabilities. Present ones carry their behavior. */ + readonly sessionConfig: AcpCapability; + readonly mcpInjection: AcpCapability; + readonly imagePrompts: AcpCapability; + + /** Session config option ids this dialect can set, for the settings page. */ + readonly configOptionIds: readonly string[]; +}; + +export type AcpDialect = AcpDialectBase & AcpUsageContract & AcpCloseContract & AcpLoadContract; + +/** + * Identity helper that pins a descriptor to the `AcpDialect` contract at its + * definition site. Without it a dialect file only fails to typecheck where it + * is consumed, which hides the error from the file that caused it. + */ +export function defineAcpDialect(dialect: T): T { + return dialect; +} + +// ── Host-facing callbacks ──────────────────────────────────────────────────── + +export type AcpSlashCommand = { + name: string; + description: string; + /** Hint text the agent supplies for a command that takes an argument. */ + inputHint: string | null; +}; + +export type AcpConfigOptionSnapshot = { + options: AcpSessionConfigOption[]; + currentModeId: string | null; + availableModeIds: string[]; +}; + +/** Exhaustiveness guard. Replaces a switch default. */ +export function assertNever(value: never, label: string): never { + throw new Error(`${label}: unexpected value ${JSON.stringify(value)}`); +} diff --git a/apps/desktop/src/main/services/chat/acpHost/acpPermissionBridge.ts b/apps/desktop/src/main/services/chat/acpHost/acpPermissionBridge.ts new file mode 100644 index 0000000000..7c82ed3e70 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpPermissionBridge.ts @@ -0,0 +1,245 @@ +/** + * Bridge `session/request_permission` to an ADE pending-input surface. + * + * The agent blocks on this request. Three rules follow from that. + * + * 1. Every request must get an answer. A dropped request hangs the turn with no + * visible cause. + * 2. A cancelled turn must answer every request that is still open, with + * `outcome: "cancelled"`. Otherwise the agent waits for a card the user can + * no longer see. + * 3. A closing connection must reject the waiters, not leave them pending. + * + * ## Option kinds + * + * ACP defines four option kinds. Grok has shipped options with no `kind` at + * all, and with ids such as `enable-always-approve`. The bridge therefore + * derives a kind from the option id and name when the wire omits it, so the + * host always knows which option means "allow" and which means "reject". + */ + +import type { PendingInputOption, PendingInputRequest } from "../../../../shared/types"; +import type { + AcpPermissionOption, + AcpPermissionOptionKind, + AcpRequestPermissionResponse, + AcpToolCallUpdate, +} from "./acpProtocolTypes"; +import { normalizeAcpPermissionRequest } from "./acpProtocolTypes"; + +export type AcpNormalizedPermissionOption = { + optionId: string; + name: string; + kind: AcpPermissionOptionKind; + /** True when the kind came from the wire rather than from id matching. */ + kindFromWire: boolean; +}; + +const WIRE_KINDS: ReadonlySet = new Set([ + "allow_once", + "allow_always", + "reject_once", + "reject_always", +]); + +/** + * Ordered id and name patterns, most specific first. + * + * "always" must be tested before the bare allow and reject words, or + * `enable-always-approve` matches "approve" and loses its "always" meaning. + */ +const KIND_PATTERNS: ReadonlyArray<{ kind: AcpPermissionOptionKind; pattern: RegExp }> = [ + { kind: "reject_always", pattern: /(reject|deny|disallow|never|no)[-_ ]?.*always|always[-_ ]?.*(reject|deny|disallow)|never/i }, + { kind: "allow_always", pattern: /always|persist|remember|session|enable[-_ ]?always/i }, + { kind: "reject_once", pattern: /reject|deny|disallow|decline|cancel|no/i }, + { kind: "allow_once", pattern: /allow|approve|accept|yes|proceed|continue|ok/i }, +]; + +/** Derive a permission option kind when the agent did not send one. */ +export function normalizePermissionOption(option: AcpPermissionOption): AcpNormalizedPermissionOption { + if (option.kind && WIRE_KINDS.has(option.kind)) { + return { optionId: option.optionId, name: option.name, kind: option.kind, kindFromWire: true }; + } + const haystack = `${option.optionId} ${option.name}`; + for (const entry of KIND_PATTERNS) { + if (entry.pattern.test(haystack)) { + return { optionId: option.optionId, name: option.name, kind: entry.kind, kindFromWire: false }; + } + } + // An option ADE cannot classify is treated as a one-time allow, because the + // agent only offers options it is willing to act on and the user still reads + // the label. Never silently treat it as an "always". + return { optionId: option.optionId, name: option.name, kind: "allow_once", kindFromWire: false }; +} + +export type AcpPendingPermission = { + /** Stable id ADE uses for the card and for the resolution receipt. */ + requestId: string; + sessionId: string; + toolCall: AcpToolCallUpdate; + options: AcpNormalizedPermissionOption[]; + turnId: string | null; + /** Answer with one of the offered option ids. Safe to call twice. */ + select(optionId: string): void; + /** Answer `cancelled`. Safe to call twice. */ + cancel(): void; +}; + +export type AcpPermissionBridgeCallbacks = { + /** Raise a card. The bridge is waiting for `select` or `cancel`. */ + onPermissionRequested: (pending: AcpPendingPermission) => void; + /** The request settled. Drop the card. */ + onPermissionSettled: (requestId: string, outcome: "selected" | "cancelled" | "closed") => void; +}; + +export type AcpPermissionBridge = { + /** Wire this into `connection.onRequest("session/request_permission", ...)`. */ + handleRequest(params: unknown): Promise; + /** Answer every open request with `cancelled`. Call this when a turn stops. */ + cancelAll(reason: string): void; + /** Reject every open request. Call this when the connection goes away. */ + rejectAll(reason: string): void; + /** Open request ids, oldest first. Diagnostics and tests. */ + openRequestIds(): string[]; + /** Current turn id stamped onto new requests. */ + setTurnId(turnId: string | null): void; +}; + +type OpenRequest = { + settle: (response: AcpRequestPermissionResponse) => void; + fail: (error: Error) => void; + settled: boolean; +}; + +export type CreateAcpPermissionBridgeArgs = { + callbacks: AcpPermissionBridgeCallbacks; + /** Mints the ADE-facing request id. Overridable so tests stay deterministic. */ + generateRequestId?: () => string; +}; + +export function createAcpPermissionBridge(args: CreateAcpPermissionBridgeArgs): AcpPermissionBridge { + const open = new Map(); + let counter = 0; + let turnId: string | null = null; + const nextId = args.generateRequestId ?? (() => `acp-perm-${++counter}`); + + const settleWith = (requestId: string, response: AcpRequestPermissionResponse, outcome: "selected" | "cancelled") => { + const entry = open.get(requestId); + if (!entry || entry.settled) return; + entry.settled = true; + open.delete(requestId); + entry.settle(response); + args.callbacks.onPermissionSettled(requestId, outcome); + }; + + return { + handleRequest: (params: unknown) => + new Promise((resolve, reject) => { + const request = normalizeAcpPermissionRequest(params); + if (!request) { + // A malformed request still gets an answer, so the agent moves on. + resolve({ outcome: { outcome: "cancelled" } }); + return; + } + const requestId = nextId(); + open.set(requestId, { settle: resolve, fail: reject, settled: false }); + const pending: AcpPendingPermission = { + requestId, + sessionId: request.sessionId, + toolCall: request.toolCall, + options: request.options.map(normalizePermissionOption), + turnId, + select: (optionId: string) => { + settleWith(requestId, { outcome: { outcome: "selected", optionId } }, "selected"); + }, + cancel: () => { + settleWith(requestId, { outcome: { outcome: "cancelled" } }, "cancelled"); + }, + }; + try { + args.callbacks.onPermissionRequested(pending); + } catch (error) { + // The host could not raise a card. Fail closed: answer `cancelled` + // so the agent stops instead of waiting on a card that never came. + settleWith(requestId, { outcome: { outcome: "cancelled" } }, "cancelled"); + void error; + } + }), + cancelAll: (reason: string) => { + for (const requestId of [...open.keys()]) { + settleWith(requestId, { outcome: { outcome: "cancelled" }, _meta: { reason } }, "cancelled"); + } + }, + rejectAll: (reason: string) => { + for (const [requestId, entry] of [...open.entries()]) { + if (entry.settled) continue; + entry.settled = true; + open.delete(requestId); + entry.fail(new Error(`ACP permission request abandoned: ${reason}`)); + args.callbacks.onPermissionSettled(requestId, "closed"); + } + }, + openRequestIds: () => [...open.keys()], + setTurnId: (value: string | null) => { + turnId = value; + }, + }; +} + +/** + * Shape a pending permission as an ADE `PendingInputRequest`. + * + * `source` stays a parameter because W1 adds the `"acp"` member to + * `PendingInputSource`. Until that lands the caller passes an existing member, + * and no cast is needed anywhere in this module. + */ +export function pendingPermissionToInputRequest(args: { + pending: AcpPendingPermission; + source: PendingInputRequest["source"]; + providerLabel: string; +}): PendingInputRequest { + const { pending, providerLabel } = args; + const title = pending.toolCall.title ?? pending.toolCall.name ?? "Tool call"; + const options: PendingInputOption[] = pending.options.map((option) => ({ + label: option.name, + value: option.optionId, + ...(option.kind === "allow_once" ? { recommended: true } : {}), + description: + option.kind === "allow_always" + ? "Allow this and every later request of this kind." + : option.kind === "reject_always" + ? "Reject this and every later request of this kind." + : option.kind === "reject_once" + ? "Reject this request only." + : "Allow this request only.", + })); + return { + requestId: pending.requestId, + itemId: pending.toolCall.toolCallId, + source: args.source, + kind: "approval", + title: `${providerLabel} needs permission`, + description: title, + questions: [ + { + id: "decision", + question: title, + options, + }, + ], + allowsFreeform: false, + blocking: true, + canProceedWithoutAnswer: false, + options, + turnId: pending.turnId, + providerMetadata: { + toolCallId: pending.toolCall.toolCallId, + toolKind: pending.toolCall.kind ?? null, + optionKinds: pending.options.map((option) => ({ + optionId: option.optionId, + kind: option.kind, + kindFromWire: option.kindFromWire, + })), + }, + }; +} diff --git a/apps/desktop/src/main/services/chat/acpHost/acpPromptBlocks.test.ts b/apps/desktop/src/main/services/chat/acpHost/acpPromptBlocks.test.ts new file mode 100644 index 0000000000..cc22aa5ea6 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpPromptBlocks.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { buildAcpPromptBlocks, type AcpResolvedAttachment } from "./acpPromptBlocks"; + +const imagePrompt = ({ + base64Data, + mimeType, + uri, +}: { + base64Data: string; + mimeType: string; + uri?: string | null; +}) => ({ + type: "image" as const, + data: base64Data, + mimeType, + ...(uri ? { uri } : {}), +}); + +function attachment( + path: string, + type: AcpResolvedAttachment["type"], + extra: Partial = {}, +): AcpResolvedAttachment { + return { + path, + type, + _resolvedPath: path, + _rootPath: "/lane", + ...extra, + } as AcpResolvedAttachment; +} + +describe("buildAcpPromptBlocks", () => { + it("sends supported local images as ACP image blocks", async () => { + const blocks = await buildAcpPromptBlocks({ + promptText: "Describe this image.", + attachments: [attachment("assets/example.png", "image")], + agentSupportsImages: true, + imagePrompt, + readAttachmentBytes: async () => Buffer.from("png-bytes"), + }); + + expect(blocks).toEqual([ + { type: "text", text: "Describe this image." }, + { type: "image", data: Buffer.from("png-bytes").toString("base64"), mimeType: "image/png" }, + ]); + }); + + it("keeps unsupported images visible as an explicit provider hint", async () => { + const blocks = await buildAcpPromptBlocks({ + promptText: "Review the attachment.", + attachments: [attachment("assets/example.png", "image")], + agentSupportsImages: false, + imagePrompt: null, + readAttachmentBytes: async () => Buffer.from("png-bytes"), + }); + + expect(blocks).toEqual([ + { type: "text", text: "Review the attachment." }, + { + type: "text", + text: "\nImage attachment omitted: assets/example.png (this provider does not support image prompts).", + }, + ]); + }); + + it("forwards image URLs through the dialect image behavior without downloading them", async () => { + const blocks = await buildAcpPromptBlocks({ + promptText: "Review the remote image.", + attachments: [attachment("https://example.test/image.webp", "image-url", { + url: "https://example.test/image.webp", + })], + agentSupportsImages: true, + imagePrompt, + readAttachmentBytes: async () => { + throw new Error("the URL must not be downloaded by the host"); + }, + }); + + expect(blocks).toEqual([ + { type: "text", text: "Review the remote image." }, + { + type: "image", + data: "", + mimeType: "image/webp", + uri: "https://example.test/image.webp", + }, + ]); + }); + + it("includes bounded text files in the ACP request", async () => { + const blocks = await buildAcpPromptBlocks({ + promptText: "Review the file.", + attachments: [attachment("README.md", "file")], + agentSupportsImages: false, + imagePrompt: null, + readAttachmentBytes: async () => Buffer.from("# Hello"), + }); + + expect(blocks).toEqual([ + { type: "text", text: "Review the file." }, + { type: "text", text: "\n[File: README.md]\n# Hello" }, + ]); + }); +}); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpPromptBlocks.ts b/apps/desktop/src/main/services/chat/acpHost/acpPromptBlocks.ts new file mode 100644 index 0000000000..214bd09967 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpPromptBlocks.ts @@ -0,0 +1,117 @@ +/** + * Convert ADE attachments into ACP prompt blocks. + * + * ACP has no generic file-path block. Images therefore use the dialect's + * image behavior when both the protocol peer and the dialect support inline + * images; text files are represented as bounded text with an explicit path + * label. Unsupported or unreadable attachments become visible text hints so + * they are never silently recorded in ADE while being absent from the model's + * request. + */ + +import path from "node:path"; +import type { AgentChatFileRef } from "../../../../shared/types/chat"; +import { hasNullByte } from "../../shared/utils"; +import { + exceedsProviderInlineLimit, + inlineAttachmentHintPart, +} from "../attachmentInlineGuard"; +import type { AcpImagePromptBehavior } from "./acpHostTypes"; +import type { AcpContentBlock } from "./acpProtocolTypes"; + +export type AcpResolvedAttachment = AgentChatFileRef & { + _resolvedPath: string; + _rootPath: string; +}; + +type AcpTextBlock = Extract; + +const MAX_INLINE_TEXT_BYTES = 512 * 1024; + +function imageMimeType(filePath: string): string { + const extension = path.extname(filePath.split(/[?#]/u, 1)[0] ?? "").toLowerCase(); + switch (extension) { + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".png": + default: + return "image/png"; + } +} + +function textBlock(text: string): AcpTextBlock { + return { type: "text", text }; +} + +function attachmentUnavailableText(attachment: AcpResolvedAttachment): AcpTextBlock { + return textBlock(`\nAttachment unavailable: ${attachment.path}`); +} + +export type BuildAcpPromptBlocksArgs = { + promptText: string; + attachments: readonly AcpResolvedAttachment[]; + agentSupportsImages: boolean; + imagePrompt: AcpImagePromptBehavior | null; + readAttachmentBytes: (attachment: AcpResolvedAttachment) => Promise; +}; + +export async function buildAcpPromptBlocks( + args: BuildAcpPromptBlocksArgs, +): Promise { + const blocks: AcpContentBlock[] = [textBlock(args.promptText)]; + + for (const attachment of args.attachments) { + if (attachment.type === "image-url") { + const url = attachment.url?.trim(); + if (!url) { + blocks.push(attachmentUnavailableText(attachment)); + } else if (args.agentSupportsImages && args.imagePrompt) { + // ACP peers can resolve a URI without ADE downloading arbitrary user + // URLs in the main process. The empty data field is required by the + // protocol schema; the URI is the actual image source. + blocks.push(args.imagePrompt({ + base64Data: "", + mimeType: imageMimeType(url), + uri: url, + })); + } else { + blocks.push(textBlock(`\nImage URL attachment omitted: ${url} (this provider does not support image prompts).`)); + } + continue; + } + + try { + const bytes = await args.readAttachmentBytes(attachment); + if (attachment.type === "image") { + if (!args.agentSupportsImages || !args.imagePrompt) { + blocks.push(textBlock(`\nImage attachment omitted: ${attachment.path} (this provider does not support image prompts).`)); + } else if (exceedsProviderInlineLimit(bytes.byteLength)) { + blocks.push(inlineAttachmentHintPart(attachment.path, bytes.byteLength)); + } else { + blocks.push(args.imagePrompt({ + base64Data: bytes.toString("base64"), + mimeType: imageMimeType(attachment._resolvedPath || attachment.path), + })); + } + continue; + } + + if (bytes.byteLength > MAX_INLINE_TEXT_BYTES) { + blocks.push(inlineAttachmentHintPart(attachment.path, bytes.byteLength)); + } else if (hasNullByte(bytes)) { + blocks.push(textBlock(`\nAttachment omitted: ${attachment.path} (binary or unsupported file).`)); + } else { + blocks.push(textBlock(`\n[File: ${attachment.path}]\n${bytes.toString("utf8")}`)); + } + } catch { + blocks.push(attachmentUnavailableText(attachment)); + } + } + + return blocks; +} diff --git a/apps/desktop/src/main/services/chat/acpHost/acpProtocolTypes.ts b/apps/desktop/src/main/services/chat/acpHost/acpProtocolTypes.ts new file mode 100644 index 0000000000..19b52eeefd --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpProtocolTypes.ts @@ -0,0 +1,684 @@ +/** + * Agent Client Protocol (ACP) wire types, protocol version 1. + * + * These declarations mirror `@agentclientprotocol/sdk@1.4.0` + * (`dist/schema/types.gen.d.ts`). Only the parts the ADE host uses are here. + * + * Why the types live in this repository and not in a dependency: + * + * 1. The published SDK is ESM only (`"type": "module"`), and it declares a + * `zod` peer dependency. The desktop main process bundles to CommonJS. A + * runtime import would add an ESM/CJS interop problem and a second zod copy + * for no gain, because the host does not use the SDK's `Connection` class. + * 2. The host must control the transport. It needs a process-tree kill, a + * connection pool, a cancel that some dialects send as a notification, and + * tolerance for banner text on stdout. The SDK transport models none of + * these. + * 3. The worktree `node_modules` is a symbolic link into the developer's live + * checkout. An install there writes to shared state that a running ADE brain + * uses. See AGENTS.md, "Ways to hurt yourself". + * + * Keep this file in step with the schema when the protocol changes. Compare it + * against `schema/schema.json` in the published package. + */ + +/** Wire values are plain JSON. */ +export type AcpJsonValue = + | null + | boolean + | number + | string + | AcpJsonValue[] + | { [key: string]: AcpJsonValue }; + +export type AcpMeta = { [key: string]: unknown } | null; + +export type AcpSessionId = string; +export type AcpToolCallId = string; +export type AcpMessageId = string; +export type AcpPermissionOptionId = string; + +/** The protocol version this host speaks. Do not target the v2 draft. */ +export const ACP_PROTOCOL_VERSION = 1; + +// ── Method names ───────────────────────────────────────────────────────────── + +export const ACP_METHOD = { + initialize: "initialize", + authenticate: "authenticate", + sessionNew: "session/new", + sessionLoad: "session/load", + sessionResume: "session/resume", + sessionPrompt: "session/prompt", + sessionCancel: "session/cancel", + sessionClose: "session/close", + sessionList: "session/list", + sessionSetMode: "session/set_mode", + sessionSetModel: "session/set_model", + sessionSetConfigOption: "session/set_config_option", + /** Agent to client. Notification. */ + sessionUpdate: "session/update", + /** Agent to client. Request. */ + sessionRequestPermission: "session/request_permission", + /** Agent to client. Requests, only when the client advertises `fs`. */ + fsReadTextFile: "fs/read_text_file", + fsWriteTextFile: "fs/write_text_file", + /** Agent to client. Requests, only when the client advertises `terminal`. */ + terminalCreate: "terminal/create", + terminalOutput: "terminal/output", + terminalWaitForExit: "terminal/wait_for_exit", + terminalKill: "terminal/kill", + terminalRelease: "terminal/release", +} as const; + +export type AcpMethodName = (typeof ACP_METHOD)[keyof typeof ACP_METHOD]; + +// ── Content ────────────────────────────────────────────────────────────────── + +export type AcpTextContentBlock = { type: "text"; text: string; _meta?: AcpMeta }; +export type AcpImageContentBlock = { + type: "image"; + data: string; + mimeType: string; + uri?: string | null; + _meta?: AcpMeta; +}; +export type AcpAudioContentBlock = { + type: "audio"; + data: string; + mimeType: string; + _meta?: AcpMeta; +}; +export type AcpResourceLinkContentBlock = { + type: "resource_link"; + uri: string; + name: string; + title?: string | null; + description?: string | null; + mimeType?: string | null; + size?: number | null; + _meta?: AcpMeta; +}; +export type AcpEmbeddedResourceContentBlock = { + type: "resource"; + resource: { uri: string; mimeType?: string | null; text?: string; blob?: string }; + _meta?: AcpMeta; +}; + +export type AcpContentBlock = + | AcpTextContentBlock + | AcpImageContentBlock + | AcpAudioContentBlock + | AcpResourceLinkContentBlock + | AcpEmbeddedResourceContentBlock; + +// ── Tool calls ─────────────────────────────────────────────────────────────── + +export type AcpToolKind = + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + +export type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed"; + +export type AcpDiff = { + path: string; + oldText?: string | null; + newText: string; + _meta?: AcpMeta; +}; + +export type AcpToolCallContent = + | { type: "content"; content: AcpContentBlock; _meta?: AcpMeta } + | ({ type: "diff" } & AcpDiff) + | { type: "terminal"; terminalId: string; _meta?: AcpMeta }; + +export type AcpToolCallLocation = { path: string; line?: number | null; _meta?: AcpMeta }; + +export type AcpToolCall = { + toolCallId: AcpToolCallId; + title: string; + name?: string | null; + kind?: AcpToolKind; + status?: AcpToolCallStatus; + content?: AcpToolCallContent[]; + locations?: AcpToolCallLocation[]; + rawInput?: unknown; + rawOutput?: unknown; + _meta?: AcpMeta; +}; + +export type AcpToolCallUpdate = { + toolCallId: AcpToolCallId; + kind?: AcpToolKind | null; + status?: AcpToolCallStatus | null; + title?: string | null; + name?: string | null; + content?: AcpToolCallContent[] | null; + locations?: AcpToolCallLocation[] | null; + rawInput?: unknown; + rawOutput?: unknown; + _meta?: AcpMeta; +}; + +// ── Plan ───────────────────────────────────────────────────────────────────── + +export type AcpPlanEntryStatus = "pending" | "in_progress" | "completed"; +export type AcpPlanEntryPriority = "high" | "medium" | "low"; + +export type AcpPlanEntry = { + content: string; + priority?: AcpPlanEntryPriority; + status: AcpPlanEntryStatus; + _meta?: AcpMeta; +}; + +export type AcpPlan = { entries: AcpPlanEntry[]; _meta?: AcpMeta }; + +// ── Session configuration ──────────────────────────────────────────────────── + +export type AcpSessionMode = { + id: string; + name: string; + description?: string | null; + _meta?: AcpMeta; +}; + +export type AcpSessionModeState = { + currentModeId: string; + availableModes: AcpSessionMode[]; + _meta?: AcpMeta; +}; + +export type AcpSessionConfigOption = { + id: string; + name: string; + type?: "select" | "boolean"; + description?: string | null; + category?: string | null; + value?: unknown; + options?: Array<{ id: string; name: string; description?: string | null }>; + _meta?: AcpMeta; +}; + +/** + * Copilot 1.0.82 (and possibly other agents) send `currentValue` instead of + * `value`, and nested choices as `{ value, name }` instead of `{ id, name }`. + * Canonicalize onto ADE's `value` / `options[].id` shape so a live snapshot + * does not land as "no current mode". + */ +export function normalizeAcpConfigOption(raw: unknown): AcpSessionConfigOption | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const record = raw as Record; + if (typeof record.id !== "string" || !record.id.length) return null; + if (typeof record.name !== "string" || !record.name.length) return null; + + const current = record.currentValue !== undefined ? record.currentValue : record.value; + const nested = Array.isArray(record.options) ? record.options : []; + const options = nested.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const row = entry as Record; + const id = typeof row.id === "string" && row.id.length + ? row.id + : typeof row.value === "string" && row.value.length + ? row.value + : ""; + if (!id) return []; + const name = typeof row.name === "string" && row.name.length ? row.name : id; + return [{ + id, + name, + ...(typeof row.description === "string" ? { description: row.description } : {}), + }]; + }); + + return { + id: record.id, + name: record.name, + ...(record.type === "select" || record.type === "boolean" ? { type: record.type } : {}), + ...(typeof record.description === "string" ? { description: record.description } : {}), + ...(typeof record.category === "string" ? { category: record.category } : {}), + ...(current !== undefined ? { value: current } : {}), + ...(options.length ? { options } : {}), + }; +} + +export function normalizeAcpConfigOptions(raw: unknown): AcpSessionConfigOption[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((entry) => { + const option = normalizeAcpConfigOption(entry); + return option ? [option] : []; + }); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +const ACP_SESSION_UPDATE_NAMES: ReadonlySet = new Set([ + "user_message_chunk", + "agent_message_chunk", + "agent_thought_chunk", + "tool_call", + "tool_call_update", + "plan", + "plan_update", + "plan_removed", + "available_commands_update", + "current_mode_update", + "config_option_update", + "session_info_update", + "usage_update", + "compaction_update", + "compaction_summary_chunk", +]); + +const ACP_CONTENT_BLOCK_NAMES: ReadonlySet = new Set([ + "text", + "image", + "audio", + "resource_link", + "resource", +]); + +const ACP_TOOL_KINDS: ReadonlySet = new Set([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", +]); + +const ACP_TOOL_STATUSES: ReadonlySet = new Set([ + "pending", + "in_progress", + "completed", + "failed", +]); + +const ACP_PERMISSION_KINDS: ReadonlySet = new Set([ + "allow_once", + "allow_always", + "reject_once", + "reject_always", +]); + +/** Accept only JSON-RPC ids that can identify a pending request. */ +export function normalizeAcpRpcId(raw: unknown): AcpRpcId | undefined { + if (typeof raw === "string") return raw; + return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; +} + +/** Normalize an error object before it crosses into the typed RPC layer. */ +export function normalizeAcpRpcError(raw: unknown): AcpRpcErrorPayload | null { + if (!isRecord(raw) || typeof raw.code !== "number" || !Number.isFinite(raw.code) || typeof raw.message !== "string") { + return null; + } + return { + code: raw.code, + message: raw.message, + ...(Object.prototype.hasOwnProperty.call(raw, "data") ? { data: raw.data } : {}), + }; +} + +/** Keep the JSON parser's object check at the transport boundary. */ +export function normalizeAcpRpcFrame(raw: unknown): Record | null { + return isRecord(raw) ? raw : null; +} + +function isAcpContentBlock(raw: unknown): boolean { + if (!isRecord(raw) || typeof raw.type !== "string" || !ACP_CONTENT_BLOCK_NAMES.has(raw.type)) return false; + switch (raw.type) { + case "text": + return typeof raw.text === "string"; + case "image": + case "audio": + return typeof raw.data === "string" && typeof raw.mimeType === "string"; + case "resource_link": + return typeof raw.uri === "string" && typeof raw.name === "string"; + case "resource": + return isRecord(raw.resource) && typeof raw.resource.uri === "string"; + default: + return false; + } +} + +function isAcpToolCallUpdate(raw: unknown): raw is AcpToolCallUpdate { + if (!isRecord(raw) || typeof raw.toolCallId !== "string" || !raw.toolCallId.length) return false; + if (raw.kind != null && (typeof raw.kind !== "string" || !ACP_TOOL_KINDS.has(raw.kind))) return false; + if (raw.status != null && (typeof raw.status !== "string" || !ACP_TOOL_STATUSES.has(raw.status))) return false; + if (raw.title != null && typeof raw.title !== "string") return false; + if (raw.name != null && typeof raw.name !== "string") return false; + return raw.content == null || (Array.isArray(raw.content) && raw.content.every(isRecord)); +} + +function isAcpSessionUpdate(raw: unknown): raw is AcpSessionUpdate { + if (!isRecord(raw) || typeof raw.sessionUpdate !== "string" || !ACP_SESSION_UPDATE_NAMES.has(raw.sessionUpdate)) { + return false; + } + switch (raw.sessionUpdate) { + case "user_message_chunk": + case "agent_message_chunk": + case "agent_thought_chunk": + return isAcpContentBlock(raw.content); + case "tool_call": + return typeof raw.toolCallId === "string" && typeof raw.title === "string"; + case "tool_call_update": + return isAcpToolCallUpdate(raw); + case "plan": + case "plan_update": + return Array.isArray(raw.entries); + case "available_commands_update": + return Array.isArray(raw.availableCommands); + case "current_mode_update": + return typeof raw.currentModeId === "string"; + case "config_option_update": + return Array.isArray(raw.configOptions); + case "session_info_update": + return (raw.title == null || typeof raw.title === "string") + && (raw.updatedAt == null || typeof raw.updatedAt === "string"); + case "usage_update": + return typeof raw.used === "number" && typeof raw.size === "number"; + case "plan_removed": + case "compaction_update": + case "compaction_summary_chunk": + return true; + default: + return false; + } +} + +/** Normalize a session/update notification before dispatching it to a handler. */ +export function normalizeAcpSessionNotification(raw: unknown): AcpSessionNotification | null { + if (!isRecord(raw) || typeof raw.sessionId !== "string" || !raw.sessionId.length || !isAcpSessionUpdate(raw.update)) { + return null; + } + return { + sessionId: raw.sessionId, + update: raw.update, + ...(Object.prototype.hasOwnProperty.call(raw, "_meta") ? { _meta: raw._meta as AcpMeta } : {}), + }; +} + +/** Normalize session/request_permission before creating an ADE pending card. */ +export function normalizeAcpPermissionRequest(raw: unknown): AcpRequestPermissionRequest | null { + if (!isRecord(raw) || typeof raw.sessionId !== "string" || !raw.sessionId.length || !isAcpToolCallUpdate(raw.toolCall)) { + return null; + } + if (!Array.isArray(raw.options)) return null; + const options = raw.options.flatMap((entry) => { + if (!isRecord(entry) || typeof entry.optionId !== "string" || !entry.optionId.length || typeof entry.name !== "string") { + return []; + } + if (entry.kind != null && (typeof entry.kind !== "string" || !ACP_PERMISSION_KINDS.has(entry.kind))) return []; + return [{ + optionId: entry.optionId, + name: entry.name, + ...(entry.kind != null ? { kind: entry.kind as AcpPermissionOptionKind } : {}), + }]; + }); + if (options.length !== raw.options.length) return null; + return { + sessionId: raw.sessionId, + toolCall: raw.toolCall, + options, + ...(Object.prototype.hasOwnProperty.call(raw, "_meta") ? { _meta: raw._meta as AcpMeta } : {}), + }; +} + +export type AcpAvailableCommand = { + name: string; + description: string; + input?: { hint: string } | null; + _meta?: AcpMeta; +}; + +// ── Usage ──────────────────────────────────────────────────────────────────── + +/** `session/update` variant `usage_update`. Reports context window occupancy. */ +export type AcpUsageUpdate = { + used: number; + size: number; + cost?: { amount: number; currency: string } | null; + _meta?: AcpMeta; +}; + +/** `session/prompt` result field. Reports token counts for the turn. */ +export type AcpPromptUsage = { + totalTokens?: number; + inputTokens?: number; + outputTokens?: number; + thoughtTokens?: number | null; + cachedReadTokens?: number | null; + cachedWriteTokens?: number | null; + _meta?: AcpMeta; +}; + +// ── session/update ─────────────────────────────────────────────────────────── + +export type AcpContentChunk = { + content: AcpContentBlock; + messageId?: AcpMessageId | null; + _meta?: AcpMeta; +}; + +export type AcpSessionUpdate = + | ({ sessionUpdate: "user_message_chunk" } & AcpContentChunk) + | ({ sessionUpdate: "agent_message_chunk" } & AcpContentChunk) + | ({ sessionUpdate: "agent_thought_chunk" } & AcpContentChunk) + | ({ sessionUpdate: "tool_call" } & AcpToolCall) + | ({ sessionUpdate: "tool_call_update" } & AcpToolCallUpdate) + | ({ sessionUpdate: "plan" } & AcpPlan) + | ({ sessionUpdate: "plan_update" } & AcpPlan) + | { sessionUpdate: "plan_removed"; _meta?: AcpMeta } + | { sessionUpdate: "available_commands_update"; availableCommands: AcpAvailableCommand[]; _meta?: AcpMeta } + | { sessionUpdate: "current_mode_update"; currentModeId: string; _meta?: AcpMeta } + | { sessionUpdate: "config_option_update"; configOptions: AcpSessionConfigOption[]; _meta?: AcpMeta } + | { sessionUpdate: "session_info_update"; title?: string | null; updatedAt?: string | null; _meta?: AcpMeta } + | ({ sessionUpdate: "usage_update" } & AcpUsageUpdate) + | { sessionUpdate: "compaction_update"; _meta?: AcpMeta } + | { sessionUpdate: "compaction_summary_chunk"; _meta?: AcpMeta }; + +export type AcpSessionNotification = { + sessionId: AcpSessionId; + update: AcpSessionUpdate; + _meta?: AcpMeta; +}; + +// ── Permissions ────────────────────────────────────────────────────────────── + +export type AcpPermissionOptionKind = + | "allow_once" + | "allow_always" + | "reject_once" + | "reject_always"; + +export type AcpPermissionOption = { + optionId: AcpPermissionOptionId; + name: string; + /** Optional on the wire in practice. Grok has shipped options with no kind. */ + kind?: AcpPermissionOptionKind; + _meta?: AcpMeta; +}; + +export type AcpRequestPermissionRequest = { + sessionId: AcpSessionId; + toolCall: AcpToolCallUpdate; + options: AcpPermissionOption[]; + _meta?: AcpMeta; +}; + +export type AcpRequestPermissionOutcome = + | { outcome: "cancelled" } + | { outcome: "selected"; optionId: AcpPermissionOptionId; _meta?: AcpMeta }; + +export type AcpRequestPermissionResponse = { + outcome: AcpRequestPermissionOutcome; + _meta?: AcpMeta; +}; + +// ── Lifecycle ──────────────────────────────────────────────────────────────── + +export type AcpImplementation = { name: string; title?: string | null; version: string }; + +export type AcpFileSystemCapabilities = { + readTextFile?: boolean; + writeTextFile?: boolean; +}; + +export type AcpClientCapabilities = { + fs?: AcpFileSystemCapabilities; + terminal?: boolean; + session?: { compaction?: unknown; configOptions?: unknown } | null; + _meta?: AcpMeta; +}; + +export type AcpPromptCapabilities = { + image?: boolean; + audio?: boolean; + embeddedContext?: boolean; +}; + +export type AcpSessionCapabilities = { + list?: unknown; + delete?: unknown; + fork?: unknown; + resume?: unknown; + close?: unknown; + additionalDirectories?: unknown; +}; + +export type AcpMcpCapabilities = { http?: boolean; sse?: boolean; acp?: boolean }; + +export type AcpAgentCapabilities = { + loadSession?: boolean; + promptCapabilities?: AcpPromptCapabilities; + mcpCapabilities?: AcpMcpCapabilities; + sessionCapabilities?: AcpSessionCapabilities; + _meta?: AcpMeta; +}; + +export type AcpAuthMethod = { + id: string; + name: string; + description?: string | null; + type?: "terminal"; + args?: string[]; + env?: Record; +}; + +export type AcpInitializeRequest = { + protocolVersion: number; + clientCapabilities?: AcpClientCapabilities; + clientInfo?: AcpImplementation | null; + _meta?: AcpMeta; +}; + +export type AcpInitializeResponse = { + protocolVersion: number; + agentCapabilities?: AcpAgentCapabilities; + authMethods?: AcpAuthMethod[]; + agentInfo?: AcpImplementation | null; + _meta?: AcpMeta; +}; + +export type AcpEnvVariable = { name: string; value: string }; + +export type AcpMcpServer = + | { type?: undefined; name: string; command: string; args: string[]; env: AcpEnvVariable[] } + | { type: "http"; name: string; url: string; headers: Array<{ name: string; value: string }> } + | { type: "sse"; name: string; url: string; headers: Array<{ name: string; value: string }> }; + +export type AcpNewSessionRequest = { + cwd: string; + mcpServers: AcpMcpServer[]; + additionalDirectories?: string[]; + _meta?: AcpMeta; +}; + +export type AcpNewSessionResponse = { + sessionId: AcpSessionId; + modes?: AcpSessionModeState | null; + configOptions?: AcpSessionConfigOption[] | null; + _meta?: AcpMeta; +}; + +export type AcpLoadSessionRequest = { + sessionId: AcpSessionId; + cwd: string; + mcpServers: AcpMcpServer[]; + additionalDirectories?: string[]; + _meta?: AcpMeta; +}; + +export type AcpLoadSessionResponse = { + modes?: AcpSessionModeState | null; + configOptions?: AcpSessionConfigOption[] | null; + _meta?: AcpMeta; +}; + +export type AcpPromptRequest = { + sessionId: AcpSessionId; + prompt: AcpContentBlock[]; + _meta?: AcpMeta; +}; + +export type AcpStopReason = + | "end_turn" + | "max_tokens" + | "max_turn_requests" + | "refusal" + | "cancelled"; + +export type AcpPromptResponse = { + stopReason: AcpStopReason; + usage?: AcpPromptUsage | null; + _meta?: AcpMeta; +}; + +export type AcpCancelNotification = { sessionId: AcpSessionId; _meta?: AcpMeta }; + +// ── JSON-RPC envelopes ─────────────────────────────────────────────────────── + +export type AcpRpcId = number | string; + +export type AcpRpcRequestFrame = { + jsonrpc: "2.0"; + id: AcpRpcId; + method: string; + params?: unknown; +}; + +export type AcpRpcNotificationFrame = { + jsonrpc: "2.0"; + method: string; + params?: unknown; +}; + +export type AcpRpcErrorPayload = { code: number; message: string; data?: unknown }; + +export type AcpRpcResponseFrame = { + jsonrpc: "2.0"; + id: AcpRpcId; + result?: unknown; + error?: AcpRpcErrorPayload; +}; + +export type AcpRpcFrame = AcpRpcRequestFrame | AcpRpcNotificationFrame | AcpRpcResponseFrame; + +/** JSON-RPC "method not found". A dialect probe reads this as "not supported". */ +export const ACP_RPC_METHOD_NOT_FOUND = -32601; +/** JSON-RPC "invalid request". Some agents answer an unknown notification with it. */ +export const ACP_RPC_INVALID_REQUEST = -32600; diff --git a/apps/desktop/src/main/services/chat/acpHost/acpRuntimeCoordinator.ts b/apps/desktop/src/main/services/chat/acpHost/acpRuntimeCoordinator.ts new file mode 100644 index 0000000000..60e90e853c --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpRuntimeCoordinator.ts @@ -0,0 +1,232 @@ +/** + * ACP runtime/session coordinator. + * + * The chat service owns ADE policy (which model, permission mode, and lane), + * while this module owns the protocol lifecycle: reuse-or-rebuild, pooled + * session opening, callback wiring, session config, and the runtime state that + * surrounds an open ACP session. Keeping that boundary here prevents a new + * provider quirk from growing the already-large provider-independent chat + * service. + */ + +import type { + AgentChatAcpPermissionMode, + AgentChatEvent, + AgentChatSession, + AcpChatProvider, +} from "../../../../shared/types"; +import type { Logger } from "../../logging/logger"; +import type { ChatRuntimeBudget } from "../chatRuntimeBudget"; +import { + openAcpSession, + type AcpSession, + type AcpSessionCallbacks, + type OpenAcpSessionArgs, +} from "./acpSession"; +import type { AcpPendingPermission } from "./acpPermissionBridge"; +import type { AcpSessionConfigOption } from "./acpProtocolTypes"; +import type { AcpDialect, AcpSlashCommand, AcpSpawnPlan } from "./acpHostTypes"; + +export type AcpRuntimeState = { + kind: "acp"; + provider: AcpChatProvider; + dialect: AcpDialect; + session: AcpSession; + /** Permission posture latched into this provider runtime. */ + permissionMode: AgentChatAcpPermissionMode; + /** Identity of the spawn this session was opened on. A change forces a restart. */ + invocationKey: string; + activeTurnId: string | null; + busy: boolean; + /** True when ADE asked for the stop. Never read from the agent's stopReason. */ + interrupted: boolean; + /** Set when the agent process died underneath a live turn. */ + processFailed: boolean; + pendingSteers: TSteer[]; + /** Slash commands the agent last advertised, already dialect-filtered. */ + slashCommands: AcpSlashCommand[]; + /** Config options the agent last reported, folded into the session snapshot. */ + configOptions: AcpSessionConfigOption[]; + currentModeId: string | null; + /** Permission request ids ADE raised as cards for the running turn. */ + openPermissionIds: Set; +}; + +export type AcpRuntimeOwner = { + session: AgentChatSession; + laneWorktreePath: string; + eventSequence: number; + transcriptBytesWritten: number; +}; + +export type AcpRuntimeCoordinatorCallbacks = { + onEvents: (runtime: AcpRuntimeState | null, events: AgentChatEvent[]) => void; + onPermissionRequested: (runtime: AcpRuntimeState | null, pending: AcpPendingPermission) => void; + onPermissionSettled: (runtime: AcpRuntimeState | null, requestId: string) => void; + onSlashCommands: (runtime: AcpRuntimeState | null, commands: AcpSlashCommand[]) => void; + onConfigOptions: ( + runtime: AcpRuntimeState | null, + snapshot: { options: AcpSessionConfigOption[]; currentModeId: string | null }, + ) => void; + onSessionInfo: ( + runtime: AcpRuntimeState | null, + info: { title: string | null; updatedAt: string | null }, + ) => void; + onProcessExit: ( + runtime: AcpRuntimeState | null, + detail: { code: number | null; signal: string | null; stderrTail: string }, + ) => void; + /** Assign the runtime to the owning chat before session config is applied. */ + onRuntimeCreated: (runtime: AcpRuntimeState) => void; + /** Record an open failure before it is returned to the chat service. */ + onOpenFailed: (error: unknown) => void; + /** Persist and publish the provider-ready state after the session is ready. */ + onReady: (runtime: AcpRuntimeState) => void | Promise; +}; + +export type CreateAcpRuntimeArgs = { + owner: AcpRuntimeOwner; + provider: AcpChatProvider; + dialect: AcpDialect; + spawnPlan: AcpSpawnPlan; + invocationKey: string; + permissionMode: AgentChatAcpPermissionMode; + modelToken: string | null; + existingSessionId: string | null; + supervisionPreflight: { ok: boolean; detail?: string } | null; + supervisionAlreadyNotified: boolean; + mcpServers?: OpenAcpSessionArgs["mcpServers"]; + logger: Logger; + runtimeBudget: Pick; + existingRuntime: AcpRuntimeState | null; + runtimeInvalidated: boolean; + hasExistingRuntime: boolean; + /** Close/release a non-reusable runtime owned by the chat service. */ + teardownExistingRuntime: () => void; + /** Provider-specific value for its declared mode config option. */ + nativeModeValue: string; + setResumeCommand: (command: string) => void; + spawnOverride?: OpenAcpSessionArgs["spawnOverride"]; + pool?: OpenAcpSessionArgs["pool"]; + binarySource: string; + callbacks: AcpRuntimeCoordinatorCallbacks; +}; + +/** Identity used to decide whether a process-global model/effort changed. */ +export function acpInvocationKey(plan: Pick): string { + return JSON.stringify([plan.command, plan.args]); +} + +/** True when ADE already has a transcript and must suppress `session/load` replay. */ +export function acpHasTranscript(owner: Pick): boolean { + return owner.eventSequence > 0 || owner.transcriptBytesWritten > 0; +} + +export async function createAcpRuntime( + args: CreateAcpRuntimeArgs, +): Promise> { + const existing = args.existingRuntime; + if ( + existing + && existing.provider === args.provider + && existing.invocationKey === args.invocationKey + && existing.permissionMode === args.permissionMode + && !args.runtimeInvalidated + && !existing.processFailed + && existing.session.connection.isAlive() + ) { + return existing; + } + if (args.hasExistingRuntime) args.teardownExistingRuntime(); + args.runtimeBudget.enforce(args.owner.session.id); + + let runtime: AcpRuntimeState | null = null; + let session: AcpSession; + try { + session = await openAcpSession({ + dialect: args.dialect, + cwd: args.owner.laneWorktreePath, + spawnPlan: args.spawnPlan, + sessionToken: args.owner.session.id, + existingSessionId: args.existingSessionId, + adeHasTranscript: acpHasTranscript(args.owner), + permissionMode: args.permissionMode, + supervisionPreflight: args.supervisionPreflight, + supervisionAlreadyNotified: args.supervisionAlreadyNotified, + mcpServers: args.mcpServers ?? [], + logger: args.logger, + ...(args.spawnOverride ? { spawnOverride: args.spawnOverride } : {}), + ...(args.pool ? { pool: args.pool } : {}), + callbacks: { + onEvents: (events) => args.callbacks.onEvents(runtime, events), + onPermissionRequested: (pending) => args.callbacks.onPermissionRequested(runtime, pending), + onPermissionSettled: (requestId) => args.callbacks.onPermissionSettled(runtime, requestId), + onSlashCommands: (commands) => args.callbacks.onSlashCommands(runtime, commands), + onConfigOptions: (snapshot) => args.callbacks.onConfigOptions(runtime, snapshot), + onSessionInfo: (info) => args.callbacks.onSessionInfo(runtime, info), + onProcessExit: (detail) => args.callbacks.onProcessExit(runtime, detail), + } satisfies AcpSessionCallbacks, + }); + } catch (error) { + args.callbacks.onOpenFailed(error); + throw error; + } + + runtime = { + kind: "acp", + provider: args.provider, + dialect: args.dialect, + session, + permissionMode: args.permissionMode, + invocationKey: args.invocationKey, + activeTurnId: null, + busy: false, + interrupted: false, + processFailed: false, + pendingSteers: [], + slashCommands: [], + configOptions: session.initialConfigOptions, + currentModeId: session.initialModeId, + openPermissionIds: new Set(), + }; + args.callbacks.onRuntimeCreated(runtime); + + if (args.dialect.sessionConfig.declared) { + await session.setConfigOption({ configId: "mode", value: args.nativeModeValue }).catch((error) => { + args.logger.warn("agent_chat.acp_set_mode_failed", { + sessionId: args.owner.session.id, + provider: args.provider, + error: error instanceof Error ? error.message : String(error), + }); + }); + if (args.modelToken) { + await session.setConfigOption({ configId: "model", value: args.modelToken }).catch((error) => { + args.logger.warn("agent_chat.acp_set_model_failed", { + sessionId: args.owner.session.id, + provider: args.provider, + model: args.modelToken, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + } + + if (session.initialConfigOptions.length || session.initialModeId) { + args.callbacks.onConfigOptions(runtime, { + options: session.initialConfigOptions, + currentModeId: session.initialModeId, + }); + } + + await args.callbacks.onReady(runtime); + args.setResumeCommand(`chat:${args.provider}:${args.owner.session.id}`); + args.logger.info("agent_chat.acp_runtime_ready", { + sessionId: args.owner.session.id, + provider: args.provider, + acpSessionId: session.sessionId, + entryMode: session.entryPlan.mode, + entryReason: session.entryPlan.reason, + binarySource: args.binarySource, + }); + return runtime; +} diff --git a/apps/desktop/src/main/services/chat/acpHost/acpSession.ts b/apps/desktop/src/main/services/chat/acpHost/acpSession.ts new file mode 100644 index 0000000000..414b227feb --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpSession.ts @@ -0,0 +1,506 @@ +/** + * One ADE chat, on one ACP session. + * + * This is the seam W4 wires into. It owns the order of operations that the + * dialects and the spec demand, so no caller has to remember them: + * + * acquire connection (pool) + * -> initialize (done by the pool) + * -> attach update, permission, and ignored-notification handlers + * -> session/new | session/resume | session/load + * -> post-session-new notifications (Grok's auto-mode neutralizer) + * -> prompt / cancel / prompt ... + * -> close (session/close, or process kill) + * + * ## Cancel accounting + * + * ADE records that it cancelled and reports the turn as interrupted, whatever + * `stopReason` the agent returns. Copilot has a known bug that reports a + * cancelled turn as `end_turn`, and Grok only accepts cancel as a notification, + * so there is no reply to read. The client-side flag is the only honest source. + * + * ## Replay suppression + * + * `session/load` makes the agent replay the whole conversation as + * `session/update` notifications. ADE already holds that transcript. Replaying + * it would duplicate every row. So updates are dropped while the load call is + * in flight, and only then. + * + * ## Supervision + * + * The host also decides, per turn, whether the approval cards it renders are + * real. See `acpSupervisionGuard.ts`: writes with no `session/request_permission` + * in an ask-style mode mean the agent gated itself, and the user is told once. + */ + +import { randomUUID } from "node:crypto"; +import type { AgentChatEvent } from "../../../../shared/types"; +import type { Logger } from "../../logging/logger"; +import { + AcpRpcError, + type AcpConnection, +} from "./acpConnection"; +import { + behaviorOf, + type AcpDialect, + type AcpSlashCommand, + type AcpUsageSample, +} from "./acpHostTypes"; +import { createAcpEventTranslator, usageSampleToEvents, type AcpEventTranslator } from "./acpEventTranslator"; +import { + createAcpPermissionBridge, + type AcpPendingPermission, + type AcpPermissionBridge, +} from "./acpPermissionBridge"; +import { + createAcpSupervisionGuard, + type AcpSupervisionGuard, +} from "./acpSupervisionGuard"; +import { + ACP_METHOD, + normalizeAcpConfigOptions, + type AcpContentBlock, + type AcpMcpServer, + type AcpNewSessionResponse, + type AcpPromptResponse, + type AcpSessionConfigOption, + type AcpStopReason, +} from "./acpProtocolTypes"; +import { acpSessionPool, type AcpPooledConnection, type AcpSessionPool } from "./acpSessionPool"; + +/** A turn is bounded by the user, not by a timer. Cancel is the way out. */ +const ACP_PROMPT_NO_TIMEOUT = 0; + +export type AcpSessionEntryMode = "new" | "resume" | "load"; + +export type AcpSessionEntryPlan = { + mode: AcpSessionEntryMode; + /** True when the host must drop the updates the entry call produces. */ + suppressReplay: boolean; + /** Why this mode was chosen. Diagnostics and tests. */ + reason: string; +}; + +/** + * Decide how to enter a session. + * + * Prefer `session/resume` when the dialect advertises it. Fall back to + * `session/load`, and suppress its replay when ADE already holds a transcript. + * Start fresh when there is no id, or when the dialect cannot rejoin at all. + */ +export function resolveAcpSessionEntry(args: { + dialect: AcpDialect; + existingSessionId: string | null; + adeHasTranscript: boolean; +}): AcpSessionEntryPlan { + if (!args.existingSessionId) { + return { mode: "new", suppressReplay: false, reason: "no stored session id" }; + } + if (args.dialect.loadPolicy === "never") { + return { mode: "new", suppressReplay: false, reason: "dialect cannot rejoin a session" }; + } + if (args.dialect.loadPolicy === "resume_preferred" && args.dialect.resumeSession.declared) { + return { mode: "resume", suppressReplay: false, reason: "agent advertises session/resume" }; + } + return { + mode: "load", + suppressReplay: args.adeHasTranscript, + reason: args.adeHasTranscript + ? "session/load replays history ADE already holds" + : "session/load is the only rejoin method", + }; +} + +export type AcpTurnOutcome = { + stopReason: AcpStopReason | null; + /** True when ADE cancelled, or when the agent reported a cancel. */ + interrupted: boolean; + usage: AcpUsageSample | null; + /** Events derived from the prompt result. Publish them after the stream. */ + events: AgentChatEvent[]; +}; + +export type AcpSessionCallbacks = { + /** Publish these chat events, in order. Never batches across a turn. */ + onEvents: (events: AgentChatEvent[]) => void; + /** Raise a permission card. Answer through `pending.select` or `.cancel`. */ + onPermissionRequested: (pending: AcpPendingPermission) => void; + /** The permission request settled. Drop the card. */ + onPermissionSettled: (requestId: string, outcome: "selected" | "cancelled" | "closed") => void; + /** The advertised slash command list changed. */ + onSlashCommands?: (commands: AcpSlashCommand[]) => void; + /** The agent reported session config options or a mode change. */ + onConfigOptions?: (snapshot: { options: AcpSessionConfigOption[]; currentModeId: string | null }) => void; + /** The agent reported its own session title. */ + onSessionInfo?: (info: { title: string | null; updatedAt: string | null }) => void; + /** The agent process went away. The session is dead. */ + onProcessExit?: (detail: { code: number | null; signal: string | null; stderrTail: string }) => void; +}; + +export type AcpSession = { + readonly providerId: AcpDialect["providerId"]; + readonly dialect: AcpDialect; + /** The agent's session id. Persist it; it is how a chat is resumed. */ + readonly sessionId: string; + readonly entryPlan: AcpSessionEntryPlan; + readonly connection: AcpConnection; + /** Session modes and config options the entry call reported. */ + readonly initialConfigOptions: AcpSessionConfigOption[]; + readonly initialModeId: string | null; + /** + * True once ADE has published the "this agent gates itself" notice. The + * caller persists it so a runtime restart does not repeat the line. + */ + readonly unsupervised: boolean; + + /** Run one turn. Resolves when the agent stops. */ + prompt(args: { turnId: string; blocks: AcpContentBlock[] }): Promise; + /** Stop the running turn. Answers every open permission request first. */ + cancel(reason: string): Promise; + /** Set one session config option, when the dialect supports it. */ + setConfigOption(args: { configId: string; value: string | boolean }): Promise; + /** End the session and release the pooled connection. Idempotent. */ + close(reason: string): Promise; +}; + +export type OpenAcpSessionArgs = { + dialect: AcpDialect; + /** Lane worktree. Becomes the session cwd. */ + cwd: string; + /** Already built by `dialect.buildSpawnPlan`. */ + spawnPlan: Parameters[0]["spawnPlan"]; + /** Unique per ADE chat. Drives the private pool key for Kimi. */ + sessionToken: string; + /** Provider session id ADE stored for this chat, when it has one. */ + existingSessionId?: string | null; + /** + * Abstract ACP permission mode this session opened with. The supervision + * guard needs it to know whether the user was promised a prompt at all. + */ + permissionMode?: string | null; + /** + * Verdict of a provider-specific pre-session gate, when one applies. + * `ok: false` means ADE could not confirm the agent will ask before it + * writes; the session still runs, and the guard says so out loud. + */ + supervisionPreflight?: { ok: boolean; detail?: string } | null; + /** + * True when this chat already showed the unsupervised notice in an earlier + * run. Keeps the once-per-session promise across a runtime restart. + */ + supervisionAlreadyNotified?: boolean; + /** True when ADE can already render this chat's history. */ + adeHasTranscript?: boolean; + /** MCP servers to offer. The caller already removed anything unsafe. */ + mcpServers?: AcpMcpServer[]; + callbacks: AcpSessionCallbacks; + logger?: Logger; + pool?: AcpSessionPool; + /** Test seam, forwarded to the pool and then to the connection. */ + spawnOverride?: Parameters[0]["spawnOverride"]; + handshakeTimeoutMs?: number; + idleTtlMs?: number; +}; + +export async function openAcpSession(args: OpenAcpSessionArgs): Promise { + const { dialect, callbacks } = args; + const pool = args.pool ?? acpSessionPool; + + const leased: AcpPooledConnection = await pool.acquire({ + dialect, + spawnPlan: args.spawnPlan, + poolEnvKeys: dialect.poolEnvKeys, + sessionToken: args.sessionToken, + ...(args.logger ? { logger: args.logger } : {}), + ...(args.idleTtlMs !== undefined ? { idleTtlMs: args.idleTtlMs } : {}), + ...(args.handshakeTimeoutMs !== undefined ? { handshakeTimeoutMs: args.handshakeTimeoutMs } : {}), + ...(args.spawnOverride ? { spawnOverride: args.spawnOverride } : {}), + }); + const connection = leased.connection; + const agentCapabilities = connection.initializeResult?.agentCapabilities ?? {}; + const effectiveMcpServers = filterMcpServers(dialect, args.mcpServers ?? [], agentCapabilities.mcpCapabilities ?? null); + + let sessionId = ""; + let suppressUpdates = false; + let closed = false; + let cancelRequested = false; + const unsubscribers: Array<() => void> = []; + + const translator: AcpEventTranslator = createAcpEventTranslator({ + readUsage: dialect.usageSource === "usage_update" ? (update) => { + const behavior = behaviorOf(dialect.usage); + return behavior ? behavior({ usageUpdate: update }) : null; + } : null, + includeSlashCommand: dialect.includeSlashCommand, + callbacks: { + ...(callbacks.onSlashCommands ? { onSlashCommands: callbacks.onSlashCommands } : {}), + ...(callbacks.onConfigOptions ? { onConfigOptions: callbacks.onConfigOptions } : {}), + ...(callbacks.onSessionInfo ? { onSessionInfo: callbacks.onSessionInfo } : {}), + }, + }); + + const supervision: AcpSupervisionGuard = createAcpSupervisionGuard({ + providerLabel: dialect.displayName, + permissionMode: args.permissionMode ?? null, + ...(args.supervisionPreflight && !args.supervisionPreflight.ok + ? { preflightUnverified: true } + : {}), + ...(args.supervisionAlreadyNotified ? { alreadyNotified: true } : {}), + }); + + /** Publish any supervision notice the guard produced. At most one, ever. */ + const publishSupervision = (events: AgentChatEvent[]): void => { + if (events.length) callbacks.onEvents(events); + }; + + const permissionBridge: AcpPermissionBridge = createAcpPermissionBridge({ + callbacks: { + onPermissionRequested: (pending) => { + // Counted before the card is raised. Whether the user answers is the + // user's business; what matters is that the agent asked at all. + supervision.notePermissionRequest(); + callbacks.onPermissionRequested(pending); + }, + onPermissionSettled: callbacks.onPermissionSettled, + }, + }); + + unsubscribers.push( + connection.onSessionUpdate((notification) => { + if (suppressUpdates) return; + if (sessionId && notification.sessionId && notification.sessionId !== sessionId) return; + const update = notification.update; + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { + supervision.noteToolCall(update.kind); + } + const events = translator.translate(update); + if (events.length) callbacks.onEvents(events); + }), + ); + + unsubscribers.push( + connection.onRequest( + ACP_METHOD.sessionRequestPermission, + (params) => permissionBridge.handleRequest(params), + { + // A pooled ACP process can own several protocol sessions. Reverse + // requests carry their session id, so never let chat B's permission + // bridge answer a request that belongs to chat A. + matches: (params) => { + if (!params || typeof params !== "object" || Array.isArray(params)) return false; + const requestSessionId = (params as { sessionId?: unknown }).sessionId; + return typeof requestSessionId === "string" && requestSessionId === sessionId; + }, + }, + ), + ); + + for (const method of dialect.ignoredNotificationMethods) { + // Receive and drop. Registering the handler keeps the method out of the + // "unhandled notification" path and documents that the silence is meant. + unsubscribers.push(connection.onNotification(method, () => undefined)); + } + + unsubscribers.push( + connection.onExit((exit) => { + permissionBridge.rejectAll("the agent process exited"); + callbacks.onProcessExit?.({ code: exit.code, signal: exit.signal, stderrTail: exit.stderrTail }); + }), + ); + + const entryPlan = resolveAcpSessionEntry({ + dialect, + existingSessionId: args.existingSessionId ?? null, + adeHasTranscript: args.adeHasTranscript ?? false, + }); + + let initialConfigOptions: AcpSessionConfigOption[] = []; + let initialModeId: string | null = null; + + try { + if (entryPlan.mode === "new") { + const response = await connection.request(ACP_METHOD.sessionNew, { + cwd: args.cwd, + mcpServers: effectiveMcpServers, + }); + sessionId = response.sessionId; + initialConfigOptions = normalizeAcpConfigOptions(response.configOptions ?? []); + initialModeId = response.modes?.currentModeId ?? null; + for (const notification of dialect.postSessionNewNotifications({ sessionId })) { + connection.notify(notification.method, { sessionId, ...notification.params }); + } + } else { + const storedId = args.existingSessionId as string; + const behavior = + entryPlan.mode === "resume" + ? behaviorOf(dialect.resumeSession) + : behaviorOf(dialect.loadSession); + if (!behavior) { + throw new Error(`${dialect.displayName} declares ${entryPlan.mode} but supplies no behavior.`); + } + const call = behavior({ sessionId: storedId, cwd: args.cwd, mcpServers: effectiveMcpServers }); + suppressUpdates = entryPlan.suppressReplay; + try { + const response = await connection.request(call.method, call.params); + sessionId = response.sessionId ?? storedId; + initialConfigOptions = normalizeAcpConfigOptions(response.configOptions ?? []); + initialModeId = response.modes?.currentModeId ?? null; + } finally { + suppressUpdates = false; + } + } + } catch (error) { + for (const unsubscribe of unsubscribers) unsubscribe(); + permissionBridge.rejectAll("the session could not be opened"); + leased.release(); + throw error; + } + + const detach = () => { + for (const unsubscribe of unsubscribers) unsubscribe(); + unsubscribers.length = 0; + }; + + const session: AcpSession = { + providerId: dialect.providerId, + dialect, + sessionId, + entryPlan, + connection, + initialConfigOptions, + initialModeId, + get unsupervised() { + return supervision.unsupervised; + }, + + prompt: async ({ turnId, blocks }) => { + cancelRequested = false; + translator.beginTurn(turnId); + permissionBridge.setTurnId(turnId); + // A preflight verdict was reached before the caller owned this runtime, + // so its notice waits for the first turn to have a live event path. + publishSupervision(supervision.drainQueued(turnId)); + let response: AcpPromptResponse | null = null; + try { + response = await connection.request( + ACP_METHOD.sessionPrompt, + { sessionId, prompt: blocks }, + { timeoutMs: ACP_PROMPT_NO_TIMEOUT }, + ); + } finally { + // Every open permission request belongs to the turn that just ended. + permissionBridge.cancelAll("the turn ended"); + permissionBridge.setTurnId(null); + // A failed or cancelled turn can still have written files, so the + // verdict is taken on every exit, not just the happy one. + publishSupervision(supervision.endTurn(turnId)); + } + + const usage = readPromptUsage(dialect, response); + const events = usage ? usageSampleToEvents(usage, turnId) : []; + translator.endTurn(); + return { + stopReason: response?.stopReason ?? null, + // Client-side accounting. Copilot can report `end_turn` for a turn ADE + // cancelled, so the agent's word is not the deciding one. + interrupted: cancelRequested || response?.stopReason === "cancelled", + usage, + events, + }; + }, + + cancel: async (reason: string) => { + cancelRequested = true; + // Answer the open cards first. A permission request that outlives its + // turn blocks the agent even after the cancel lands. + permissionBridge.cancelAll(reason); + if (dialect.cancelStyle === "notification") { + connection.notify(ACP_METHOD.sessionCancel, { sessionId }); + return; + } + try { + await connection.request(ACP_METHOD.sessionCancel, { sessionId }, { timeoutMs: 10_000 }); + } catch (error) { + // An agent that does not implement the request form still stops when it + // sees the notification. Fall back rather than fail the cancel. + if (error instanceof AcpRpcError && error.isMethodNotFound) { + connection.notify(ACP_METHOD.sessionCancel, { sessionId }); + return; + } + args.logger?.warn("agent_chat.acp_cancel_failed", { + provider: dialect.providerId, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + + setConfigOption: async ({ configId, value }) => { + const behavior = behaviorOf(dialect.sessionConfig); + if (!behavior) { + throw new Error(`${dialect.displayName} does not accept session config options.`); + } + const call = behavior({ sessionId, configId, value }); + await connection.request(call.method, call.params); + }, + + close: async (reason: string) => { + if (closed) return; + closed = true; + permissionBridge.cancelAll(reason); + const closeBehavior = behaviorOf(dialect.closeSession); + if (closeBehavior) { + const call = closeBehavior({ sessionId }); + try { + await connection.request(call.method, call.params, { timeoutMs: 10_000 }); + } catch (error) { + args.logger?.warn("agent_chat.acp_close_failed", { + provider: dialect.providerId, + error: error instanceof Error ? error.message : String(error), + }); + } + detach(); + leased.release(); + return; + } + // No `session/close` on this agent. The process IS the session, and the + // pool gave this session a private process, so ending it is safe. + detach(); + leased.evict(reason); + }, + }; + + return session; +} + +function filterMcpServers( + dialect: AcpDialect, + servers: AcpMcpServer[], + mcpCapabilities: { http?: boolean; sse?: boolean } | null, +): AcpMcpServer[] { + const behavior = behaviorOf(dialect.mcpInjection); + // No MCP injection capability means inject nothing. Silence is the safe + // default: an agent that receives a server it cannot reach fails the session. + if (!behavior) return []; + return behavior({ + servers, + agentSupportsHttp: mcpCapabilities?.http === true, + agentSupportsSse: mcpCapabilities?.sse === true, + }); +} + +function readPromptUsage(dialect: AcpDialect, response: AcpPromptResponse | null): AcpUsageSample | null { + if (!response) return null; + const behavior = behaviorOf(dialect.usage); + if (!behavior) return null; + return behavior({ promptResponse: response, promptUsage: response.usage ?? null }); +} + +/** Build a plain text prompt block. The common case. */ +export function textPromptBlock(text: string): { type: "text"; text: string } { + return { type: "text", text }; +} + +/** Mint a turn id when the caller has none. */ +export function newAcpTurnId(): string { + return randomUUID(); +} diff --git a/apps/desktop/src/main/services/chat/acpHost/acpSessionPool.ts b/apps/desktop/src/main/services/chat/acpHost/acpSessionPool.ts new file mode 100644 index 0000000000..1015191ddd --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpSessionPool.ts @@ -0,0 +1,267 @@ +/** + * Connection pool for ACP agent processes. + * + * The pool key is `{providerId, cwd, envHash}`. Two chats in the same lane with + * the same environment share one agent process, because a session is a + * protocol object inside that process and `session/new` is cheap. + * + * Three rules come from `droidSdkPool.ts` and `piSdkPool.ts` and are kept: + * + * - A generation counter guards release. A caller that releases a stale + * generation must not tear down the connection that replaced it. + * - A dead process is never handed out. The entry is dropped and rebuilt. + * - Concurrent acquisitions of the same key share one in-flight build. + * + * Two rules are new here: + * + * - **Idle time to live.** The last release starts a timer instead of an + * immediate kill. A user who closes a chat and opens another in the same lane + * reuses the warm process. A re-acquisition inside the window cancels the + * timer. + * - **One process per session.** A dialect whose close style is `kill_process` + * has no `session/close`, so ending a chat means ending a process. Sharing + * would make one chat's close kill another chat's session. Such a dialect + * gets a private key per session and no idle window. + */ + +import { createHash } from "node:crypto"; +import type { Logger } from "../../logging/logger"; +import { + createAcpConnection, + initializeAcpConnection, + type AcpConnection, +} from "./acpConnection"; +import type { AcpDialect, AcpSpawnPlan } from "./acpHostTypes"; + +/** How long an unused connection stays warm before the pool ends it. */ +export const ACP_IDLE_TTL_MS = 60_000; + +export type AcpPoolKeyParts = { + providerId: string; + cwd: string; + envHash: string; + /** + * Hash of the command and its arguments. + * + * This is not decoration. Several providers take the model and the reasoning + * effort as process-global spawn flags: Grok uses `-m` and + * `--reasoning-effort`, and Copilot uses `--effort`, which `session/new` + * cannot override. Without the argument hash, two chats on different models + * would share one process and the second chat would silently run on the + * first chat's model. + */ + invocationHash: string; + /** Set for a `kill_process` dialect. Makes the key private to one session. */ + privateToken?: string | null; +}; + +/** + * Hash the environment entries that change agent behavior. + * + * Hashing the whole environment would make every process share nothing, + * because ADE sets per-chat variables that do not change how the agent runs. + * The caller passes only the keys that matter for the dialect. + */ +export function hashPoolEnv(env: NodeJS.ProcessEnv, keys: readonly string[]): string { + const hash = createHash("sha256"); + for (const key of [...keys].sort()) { + hash.update(key); + hash.update("\u0000"); + hash.update(env[key] ?? ""); + hash.update("\u0000"); + } + return hash.digest("hex").slice(0, 16); +} + +/** Hash the executable and its arguments. See `invocationHash`. */ +export function hashSpawnInvocation(plan: Pick): string { + const hash = createHash("sha256"); + hash.update(plan.command); + for (const arg of plan.args) { + hash.update("\u0000"); + hash.update(arg); + } + return hash.digest("hex").slice(0, 16); +} + +export function buildAcpPoolKey(parts: AcpPoolKeyParts): string { + const base = `${parts.providerId}:${parts.cwd}:${parts.envHash}:${parts.invocationHash}`; + return parts.privateToken ? `${base}:${parts.privateToken}` : base; +} + +export type AcpPooledConnection = { + connection: AcpConnection; + generation: number; + poolKey: string; + /** Release this lease. Idempotent. */ + release: () => void; + /** End the process now, whatever the reference count says. */ + evict: (reason: string) => void; +}; + +type PoolEntry = { + connection: AcpConnection; + generation: number; + refCount: number; + idleTimer: NodeJS.Timeout | null; + idleTtlMs: number; +}; + +export type AcpSessionPool = { + acquire(args: AcquireAcpConnectionArgs): Promise; + /** Number of live entries. Diagnostics and tests. */ + size(): number; + /** True when the key currently holds a live connection. */ + has(poolKey: string): boolean; + /** End every connection. Call on shutdown. */ + disposeAll(reason: string): void; +}; + +export type AcquireAcpConnectionArgs = { + dialect: AcpDialect; + spawnPlan: AcpSpawnPlan; + /** + * Environment keys that must match for two chats to share a process. The + * dialect's config home and any model or effort flag belong here. + */ + poolEnvKeys: readonly string[]; + /** + * Unique per ADE chat session. Used only when the dialect demands one process + * per session. + */ + sessionToken: string; + logger?: Logger; + idleTtlMs?: number; + handshakeTimeoutMs?: number; + /** Test seam, forwarded to `createAcpConnection`. */ + spawnOverride?: Parameters[0]["spawnOverride"]; +}; + +export function createAcpSessionPool(): AcpSessionPool { + const entries = new Map(); + const building = new Map>(); + let generationCounter = 0; + + const dropEntry = (poolKey: string, entry: PoolEntry, reason: string) => { + if (entries.get(poolKey) !== entry) return; + entries.delete(poolKey); + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.connection.dispose(reason); + }; + + const startIdleTimer = (poolKey: string, entry: PoolEntry) => { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + if (entry.idleTtlMs <= 0) { + dropEntry(poolKey, entry, "idle, no warm window for this dialect"); + return; + } + entry.idleTimer = setTimeout(() => { + entry.idleTimer = null; + if (entry.refCount > 0) return; + dropEntry(poolKey, entry, "idle time to live elapsed"); + }, entry.idleTtlMs); + entry.idleTimer.unref?.(); + }; + + const lease = (poolKey: string, entry: PoolEntry): AcpPooledConnection => { + entry.refCount += 1; + if (entry.idleTimer) { + clearTimeout(entry.idleTimer); + entry.idleTimer = null; + } + const generation = entry.generation; + let released = false; + return { + connection: entry.connection, + generation, + poolKey, + release: () => { + if (released) return; + released = true; + const current = entries.get(poolKey); + // A stale release must not touch the connection that replaced this one. + if (!current || current.generation !== generation) return; + current.refCount = Math.max(0, current.refCount - 1); + if (current.refCount === 0) startIdleTimer(poolKey, current); + }, + evict: (reason: string) => { + released = true; + const current = entries.get(poolKey); + if (!current || current.generation !== generation) return; + dropEntry(poolKey, current, reason); + }, + }; + }; + + const build = async (poolKey: string, args: AcquireAcpConnectionArgs): Promise => { + const connection = createAcpConnection({ + dialect: args.dialect, + spawnPlan: args.spawnPlan, + logger: args.logger, + spawnOverride: args.spawnOverride, + }); + try { + await initializeAcpConnection({ + connection, + dialect: args.dialect, + ...(args.handshakeTimeoutMs !== undefined ? { timeoutMs: args.handshakeTimeoutMs } : {}), + }); + } catch (error) { + connection.dispose("handshake failed"); + throw error; + } + generationCounter += 1; + const entry: PoolEntry = { + connection, + generation: generationCounter, + refCount: 0, + idleTimer: null, + idleTtlMs: args.dialect.oneProcessPerSession ? 0 : args.idleTtlMs ?? ACP_IDLE_TTL_MS, + }; + connection.onExit(() => { + dropEntry(poolKey, entry, "agent process exited"); + }); + entries.set(poolKey, entry); + return entry; + }; + + return { + acquire: async (args: AcquireAcpConnectionArgs) => { + const poolKey = buildAcpPoolKey({ + providerId: args.dialect.providerId, + cwd: args.spawnPlan.cwd, + envHash: hashPoolEnv(args.spawnPlan.env, args.poolEnvKeys), + invocationHash: hashSpawnInvocation(args.spawnPlan), + privateToken: args.dialect.oneProcessPerSession ? args.sessionToken : null, + }); + + for (;;) { + const existing = entries.get(poolKey); + if (existing?.connection.isAlive()) return lease(poolKey, existing); + if (existing) dropEntry(poolKey, existing, "connection is not alive"); + + let inFlight = building.get(poolKey); + if (!inFlight) { + inFlight = build(poolKey, args).finally(() => building.delete(poolKey)); + building.set(poolKey, inFlight); + } + const built = await inFlight; + const current = entries.get(poolKey); + // The connection may have died, or been evicted, while it was building. + // Loop and rebuild rather than hand out a corpse. + if (current === built && built.connection.isAlive()) return lease(poolKey, built); + } + }, + size: () => entries.size, + has: (poolKey: string) => entries.get(poolKey)?.connection.isAlive() === true, + disposeAll: (reason: string) => { + for (const [poolKey, entry] of [...entries.entries()]) { + dropEntry(poolKey, entry, reason); + } + entries.clear(); + }, + }; +} + +/** Process-wide pool. W4 uses this instead of creating its own. */ +export const acpSessionPool = createAcpSessionPool(); diff --git a/apps/desktop/src/main/services/chat/acpHost/acpSupervisionGuard.ts b/apps/desktop/src/main/services/chat/acpHost/acpSupervisionGuard.ts new file mode 100644 index 0000000000..c5d73f8a5a --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/acpSupervisionGuard.ts @@ -0,0 +1,218 @@ +/** + * Catch an ACP agent that approved its own work. + * + * ADE renders approval cards for ACP providers. An agent that never sends + * `session/request_permission` makes those cards decorative: the user believes + * they are gating writes, and nothing is gated. Grok 1.0.13 does exactly that + * when it inherits the user's Claude `permissions.defaultMode`, and the fix for + * that leans on an undocumented environment variable that can disappear in any + * release. Copilot 1.0.82 does it unconditionally. + * + * So the host watches, provider-agnostically: + * + * a turn ran in an ask-style permission mode + * AND it produced `edit` or `execute` tool calls + * AND zero `session/request_permission` arrived + * => the session is unsupervised, and ADE says so. + * + * The notice fires once per session and is dismissible. It is a `system_notice` + * because it is a fact about the session, not a failure of the turn — the work + * really did happen and the transcript is real. + * + * ## Read tools are not evidence + * + * Read, search, and fetch never prompt on any of these agents. Only `edit` and + * `execute` are the kinds an ask-style mode is supposed to gate, so only those + * arm the invariant. Counting reads would fire on every well-behaved session. + * + * ## The notice reports the OBSERVATION, never the cause + * + * Silence has more than one explanation, and ADE cannot tell them apart from + * the wire. Grok evaluates per-project remembered approvals + * (`CachedStateStore` / `remember_tool_approvals`) before it ever consults the + * prompt policy, so a user who once chose "always allow" for this project — + * possibly in Grok's own TUI, in a Work terminal, outside ADE entirely — gets + * edits with zero `session/request_permission` in a session nobody broke. + * + * What is true in EVERY case is the observation: no approval request arrived, + * so ADE's approval cards could not gate the change. So that is what the + * message says. Writing "Grok approved its own file changes" would be a guess + * about who decided, and it would be wrong for the remembered-grant user — and + * a banner that is wrong often enough is a banner people learn to ignore, + * which costs more than the warning is worth. The `detail` body names both + * plausible causes so the user can act on it. + * + * ## Preflight failures land here too + * + * A provider whose pre-session gate could not confirm supervision seeds the + * guard with `preflightUnverified`. That notice fires on the first turn rather + * than at open, because the host's event callback is not live until the caller + * owns the runtime. It shares the once-per-session latch with the observed + * case: a user gets one honest line, not two. + */ + +import type { AgentChatEvent } from "../../../../shared/types"; +import type { AcpToolKind } from "./acpProtocolTypes"; + +/** + * Whether a permission mode promises the user a prompt. + * + * `plan` and `default` do. `auto-edit` deliberately stops prompting for edits, + * and `auto` / `yolo` stop prompting entirely, so silence there is the posture + * working rather than a supervision hole. + */ +export type AcpSupervisionMode = "ask" | "auto"; + +const ASK_STYLE_PERMISSION_MODES: ReadonlySet = new Set(["plan", "default"]); + +export function acpSupervisionModeFor(permissionMode: string | null | undefined): AcpSupervisionMode { + // An unknown or absent mode is treated as ask-style. ADE's own default is + // `default`, and a mode ADE cannot read is not a licence to assume the user + // opted out of approvals. + if (permissionMode == null || !permissionMode.length) return "ask"; + return ASK_STYLE_PERMISSION_MODES.has(permissionMode) ? "ask" : "auto"; +} + +/** Tool kinds an ask-style mode is supposed to gate. */ +const GATED_TOOL_KINDS: ReadonlySet = new Set([ + "edit", + "delete", + "move", + "execute", +]); + +export type AcpSupervisionGuard = { + /** True once ADE has concluded this session is not gated. */ + readonly unsupervised: boolean; + /** Record a tool call the agent reported. Kind may be absent on the wire. */ + noteToolCall(kind: AcpToolKind | null | undefined): void; + /** Record that the agent asked ADE for permission. Disarms the invariant. */ + notePermissionRequest(): void; + /** + * Close a turn and return the notices to publish. Empty in the ordinary case. + * At most one notice is ever produced across the life of the guard. + */ + endTurn(turnId: string | null): AgentChatEvent[]; + /** Notices queued before a turn ran (preflight). Drains to at most one. */ + drainQueued(turnId: string | null): AgentChatEvent[]; +}; + +export type CreateAcpSupervisionGuardArgs = { + /** Provider display name. Appears in the notice copy. */ + providerLabel: string; + /** Abstract ACP permission mode the session opened with. */ + permissionMode: string | null | undefined; + /** + * True when a provider-specific gate ran and could NOT confirm that the + * agent will ask before it writes. Absent means "no gate applies". + */ + preflightUnverified?: boolean; + /** Already fired in an earlier run of this chat. Suppresses a repeat. */ + alreadyNotified?: boolean; +}; + +/** Copy for the case ADE observed: work happened, nothing asked. */ +export function acpUnsupervisedNoticeMessage(providerLabel: string, sawExecute: boolean, sawEdit: boolean): string { + const what = sawEdit && sawExecute + ? "changed files and ran commands" + : sawExecute + ? "ran commands" + : "changed files"; + return `${providerLabel} ${what} here without asking ADE to approve. ADE's approval cards can't gate this chat.`; +} + +/** + * The two explanations ADE cannot tell apart, so it names both rather than + * picking one. Rendered as the collapsible body under the one-line message. + */ +export function acpUnsupervisedNoticeDetail(providerLabel: string): string { + return [ + `ADE received no approval request for this turn, so its approval cards had nothing to gate.`, + ``, + `Two things cause that, and ADE cannot tell them apart from here:`, + `• ${providerLabel} is approving the work itself.`, + `• You already granted an "always allow" for this project. ${providerLabel} remembers those outside ADE, including grants made in its own terminal UI.`, + ``, + `Run \`${providerLabel.toLowerCase()} inspect\` in this folder to see which permissions it loaded.`, + ].join("\n"); +} + +/** Copy for the case ADE could not verify in advance. Never claims it happened. */ +export function acpUnverifiedNoticeMessage(providerLabel: string): string { + return `ADE could not confirm that ${providerLabel} will ask before it edits files here. It may approve its own changes.`; +} + +function notice(message: string, detail: string | null, turnId: string | null): AgentChatEvent { + return { + type: "system_notice", + noticeKind: "warning", + severity: "warning", + message, + ...(detail ? { detail } : {}), + ...(turnId ? { turnId } : {}), + }; +} + +export function createAcpSupervisionGuard(args: CreateAcpSupervisionGuardArgs): AcpSupervisionGuard { + const mode = acpSupervisionModeFor(args.permissionMode); + let notified = args.alreadyNotified === true; + let unsupervised = false; + let preflightPending = args.preflightUnverified === true; + // Tool kinds are per turn: the notice describes the turn that tripped it. + let sawEdit = false; + let sawExecute = false; + // Permission requests are sticky for the session. An agent that asked once + // has proved it asks, and a user who answered `allow-edits-session` bought + // the silence in every later turn. Resetting this per turn would blame the + // agent for a decision the user made. + let sawPermissionRequest = false; + + const fire = (message: string, detail: string | null, turnId: string | null): AgentChatEvent[] => { + if (notified) return []; + notified = true; + unsupervised = true; + return [notice(message, detail, turnId)]; + }; + + const drainQueued = (turnId: string | null): AgentChatEvent[] => { + if (!preflightPending) return []; + preflightPending = false; + return fire( + acpUnverifiedNoticeMessage(args.providerLabel), + acpUnsupervisedNoticeDetail(args.providerLabel), + turnId, + ); + }; + + return { + get unsupervised() { + return unsupervised; + }, + noteToolCall: (kind) => { + if (!kind) return; + if (!GATED_TOOL_KINDS.has(kind)) return; + if (kind === "execute") sawExecute = true; + else sawEdit = true; + }, + notePermissionRequest: () => { + sawPermissionRequest = true; + }, + drainQueued, + endTurn: (turnId) => { + const turnEdit = sawEdit; + const turnExecute = sawExecute; + sawEdit = false; + sawExecute = false; + const queued = drainQueued(turnId); + if (queued.length) return queued; + if (mode !== "ask") return []; + if (sawPermissionRequest) return []; + if (!turnEdit && !turnExecute) return []; + return fire( + acpUnsupervisedNoticeMessage(args.providerLabel, turnExecute, turnEdit), + acpUnsupervisedNoticeDetail(args.providerLabel), + turnId, + ); + }, + }; +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.config-options.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.config-options.json new file mode 100644 index 0000000000..6e574edb3f --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.config-options.json @@ -0,0 +1,38 @@ +[ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + } + ], + "category": "mode" + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On" + }, + { + "value": "off", + "name": "Off" + } + ], + "category": "permissions" + } +] diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.initialize.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.initialize.json new file mode 100644 index 0000000000..63af3156b8 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.initialize.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "promptCapabilities": { + "image": true, + "audio": false, + "embeddedContext": true + }, + "sessionCapabilities": { + "list": {} + } + }, + "agentInfo": { + "name": "Copilot", + "title": "Copilot", + "version": "1.0.4" + }, + "authMethods": [ + { + "id": "copilot-login", + "name": "Log in with Copilot CLI", + "description": "Run `copilot login` in the terminal", + "_meta": { + "terminal-auth": { + "command": "/opt/homebrew/Caskroom/copilot-cli/1.0.3/copilot", + "args": [ + "login" + ], + "label": "Copilot Login" + } + } + } + ] +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.live-turn.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.live-turn.json new file mode 100644 index 0000000000..62f4f435bf --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.live-turn.json @@ -0,0 +1,176 @@ +{ + "steps": [ + { + "step": "ping", + "ok": true, + "text": "ping", + "stopReason": "end_turn", + "usage": { + "inputTokens": 18577, + "outputTokens": 48, + "totalTokens": 18625, + "thoughtTokens": 41, + "cachedReadTokens": 9066, + "cachedWriteTokens": 9501 + }, + "config": [ + { + "sessionUpdate": "config_option_update", + "configOptions": [ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "category": "mode", + "description": "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot." + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On", + "description": "Automatically approve all tool, path, and URL requests" + }, + { + "value": "off", + "name": "Off", + "description": "Require approval for tool, path, and URL requests" + } + ], + "category": "permissions", + "description": "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs." + } + ] + } + ], + "kinds": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_message_chunk" + ] + }, + { + "step": "ping-with-model-gpt-5.4", + "ok": true, + "text": "ping", + "stopReason": "end_turn", + "usage": { + "inputTokens": 18575, + "outputTokens": 42, + "totalTokens": 18617, + "thoughtTokens": 35, + "cachedReadTokens": 9066, + "cachedWriteTokens": 9499 + }, + "config": [ + { + "sessionUpdate": "config_option_update", + "configOptions": [ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "category": "mode", + "description": "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot." + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On", + "description": "Automatically approve all tool, path, and URL requests" + }, + { + "value": "off", + "name": "Off", + "description": "Require approval for tool, path, and URL requests" + } + ], + "category": "permissions", + "description": "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs." + } + ] + } + ] + }, + { + "step": "cancel-mid-prompt", + "ok": true, + "text": "1\n2\n3\n4\n5", + "stopReason": "end_turn", + "usage": null + }, + { + "step": "write-probe", + "ok": true, + "text": "Info: /var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-copilot-turn-d9MfNy/acp-probe-write.txtDone.", + "stopReason": "end_turn", + "permissionCount": 0, + "permissionTitles": [], + "kinds": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "tool_call", + "agent_message_chunk", + "tool_call_update", + "usage_update", + "agent_message_chunk", + "agent_message_chunk" + ], + "wrote": "ping" + } + ] +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.model-probe.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.model-probe.json new file mode 100644 index 0000000000..0ecf2efe2f --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.model-probe.json @@ -0,0 +1,149 @@ +{ + "steps": [ + { + "step": "prompt-default", + "ok": true, + "extraArgs": [], + "stdout": "ping\n\n" + }, + { + "step": "prompt-model-auto", + "ok": true, + "extraArgs": [ + "--model", + "auto" + ], + "stdout": "ping\n\n" + }, + { + "step": "prompt-model-gpt-5.4", + "ok": false, + "extraArgs": [ + "--model", + "gpt-5.4" + ], + "status": 1, + "stdout": "", + "stderr": "Error: Model \"gpt-5.4\" from --model flag is not available.\n" + }, + { + "step": "acp-default", + "ok": true, + "extraArgs": [], + "sessionId": "69cad382-d50a-487b-af52-862f33312e7b", + "stopReason": "end_turn", + "usage": { + "inputTokens": 18573, + "outputTokens": 53, + "totalTokens": 18626, + "thoughtTokens": 47, + "cachedReadTokens": 0, + "cachedWriteTokens": 18563 + }, + "textHint": "{\"stopReason\":\"end_turn\",\"usage\":{\"inputTokens\":18573,\"outputTokens\":53,\"totalTokens\":18626,\"thoughtTokens\":47,\"cachedReadTokens\":0,\"cachedWriteTokens\":18563}}", + "notificationUpdates": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_message_chunk" + ], + "stderrTail": "" + }, + { + "step": "acp-model-auto", + "ok": true, + "extraArgs": [ + "--model", + "auto" + ], + "sessionId": "205ed6a6-ea25-439e-b68a-d5a55ff320c7", + "stopReason": "end_turn", + "usage": { + "inputTokens": 18575, + "outputTokens": 45, + "totalTokens": 18620, + "thoughtTokens": 38, + "cachedReadTokens": 9066, + "cachedWriteTokens": 9499 + }, + "textHint": "{\"stopReason\":\"end_turn\",\"usage\":{\"inputTokens\":18575,\"outputTokens\":45,\"totalTokens\":18620,\"thoughtTokens\":38,\"cachedReadTokens\":9066,\"cachedWriteTokens\":9499}}", + "notificationUpdates": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_message_chunk" + ], + "stderrTail": "" + }, + { + "step": "acp-model-gpt-5.4", + "ok": true, + "extraArgs": [ + "--model", + "gpt-5.4" + ], + "sessionId": "c4c7449f-4ec9-4b4c-8a32-3d327b9458d3", + "stopReason": "end_turn", + "usage": { + "inputTokens": 18579, + "outputTokens": 49, + "totalTokens": 18628, + "thoughtTokens": 43, + "cachedReadTokens": 9066, + "cachedWriteTokens": 9503 + }, + "textHint": "{\"stopReason\":\"end_turn\",\"usage\":{\"inputTokens\":18579,\"outputTokens\":49,\"totalTokens\":18628,\"thoughtTokens\":43,\"cachedReadTokens\":9066,\"cachedWriteTokens\":9503}}", + "notificationUpdates": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_message_chunk" + ], + "stderrTail": "" + }, + { + "step": "acp-model-claude-sonnet-4.6", + "ok": true, + "extraArgs": [ + "--model", + "claude-sonnet-4.6" + ], + "sessionId": "3335e01a-e453-4c3b-94ee-2d23814ee6a3", + "stopReason": "end_turn", + "usage": { + "inputTokens": 18576, + "outputTokens": 52, + "totalTokens": 18628, + "thoughtTokens": 46, + "cachedReadTokens": 9066, + "cachedWriteTokens": 9500 + }, + "textHint": "{\"stopReason\":\"end_turn\",\"usage\":{\"inputTokens\":18576,\"outputTokens\":52,\"totalTokens\":18628,\"thoughtTokens\":46,\"cachedReadTokens\":9066,\"cachedWriteTokens\":9500}}", + "notificationUpdates": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_message_chunk" + ], + "stderrTail": "" + } + ] +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.trust-gate.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.trust-gate.json new file mode 100644 index 0000000000..9ce095d5c7 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilot.trust-gate.json @@ -0,0 +1,501 @@ +{ + "capturedAt": "2026-08-31T14:06:05.138Z", + "binary": "copilot", + "note": "C and B do not touch config.json. A writes JSONC with the original comment header preserved. Previous rewrites of config.json (auth/state file) made every prompt return No model available. Run C opened on a tmp cwd; repeating C/A/B on a nested independent git repo under fixtures so system-temp auto-access cannot explain the result.", + "runs": [ + { + "run": "C", + "description": "neither --add-dir nor trusted folder keys (tmp cwd)", + "addDir": false, + "seedTrusted": false, + "seedMode": false, + "disallowTempDir": false, + "cwdKind": "tmp", + "cwd": "/var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-copilot-trust-cwd-aMmwyP", + "copilotHome": "/Users/admin/.copilot", + "args": [ + "--acp" + ], + "membership": { + "snakeCount": 0, + "camelCount": 2, + "cwdInSnake": false, + "cwdInCamel": false, + "cwdUnderTrustedAncestor": false, + "tmpUnderTrustedAncestor": false + }, + "opened": true, + "deadlock": false, + "permissionCount": 0, + "permissions": [], + "wrote": "ping", + "stopReason": "end_turn", + "allowAll": "off", + "configOptions": [ + { + "id": "mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent" + }, + { + "id": "allow_all", + "currentValue": "off" + } + ], + "updateKinds": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "tool_call", + "agent_message_chunk", + "tool_call_update", + "usage_update", + "agent_message_chunk", + "agent_message_chunk" + ], + "stderrTrustHint": false, + "error": null, + "elapsedMs": 6429, + "sessionId": "6c922f5b-5740-4444-b566-d5786cd7a042", + "text": "Info: /var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-copilot-trust-cwd-aMmwyP/acp-trust-write.txtDone.", + "toolCalls": [ + { + "sessionUpdate": "tool_call", + "kind": "edit", + "title": "Creating ...de-copilot-trust-cwd-aMmwyP/acp-trust-write.txt" + }, + { + "sessionUpdate": "tool_call_update", + "kind": null, + "title": null + } + ], + "reverseRequestMethods": [] + }, + { + "run": "A", + "description": "trusted_folders snake_case only, no --add-dir (tmp cwd)", + "addDir": false, + "seedTrusted": true, + "seedMode": "snake", + "disallowTempDir": false, + "cwdKind": "tmp", + "cwd": "/var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-copilot-trust-cwd-84nTAD", + "copilotHome": "/Users/admin/.copilot", + "args": [ + "--acp" + ], + "membership": { + "snakeCount": 1, + "camelCount": 2, + "cwdInSnake": true, + "cwdInCamel": false, + "cwdUnderTrustedAncestor": true, + "tmpUnderTrustedAncestor": false + }, + "opened": true, + "deadlock": false, + "permissionCount": 0, + "permissions": [], + "wrote": "ping", + "stopReason": "end_turn", + "allowAll": "off", + "configOptions": [ + { + "id": "mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent" + }, + { + "id": "allow_all", + "currentValue": "off" + } + ], + "updateKinds": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "tool_call", + "agent_message_chunk", + "tool_call_update", + "usage_update", + "agent_message_chunk", + "agent_message_chunk" + ], + "stderrTrustHint": false, + "error": null, + "elapsedMs": 5822, + "sessionId": "562c3ee9-c2a4-41c5-9367-0189b0a1eed1", + "text": "Info: /var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-copilot-trust-cwd-84nTAD/acp-trust-write.txtDone.", + "toolCalls": [ + { + "sessionUpdate": "tool_call", + "kind": "edit", + "title": "Creating ...de-copilot-trust-cwd-84nTAD/acp-trust-write.txt" + }, + { + "sessionUpdate": "tool_call_update", + "kind": null, + "title": null + } + ], + "reverseRequestMethods": [] + }, + { + "run": "A-camel", + "description": "trustedFolders camelCase only, no --add-dir (tmp cwd)", + "addDir": false, + "seedTrusted": true, + "seedMode": "camel", + "disallowTempDir": false, + "cwdKind": "tmp", + "cwd": "/var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-copilot-trust-cwd-lTC8DY", + "copilotHome": "/Users/admin/.copilot", + "args": [ + "--acp" + ], + "membership": { + "snakeCount": 0, + "camelCount": 3, + "cwdInSnake": false, + "cwdInCamel": true, + "cwdUnderTrustedAncestor": true, + "tmpUnderTrustedAncestor": false + }, + "opened": true, + "deadlock": false, + "permissionCount": 0, + "permissions": [], + "wrote": "ping", + "stopReason": "end_turn", + "allowAll": "off", + "configOptions": [ + { + "id": "mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent" + }, + { + "id": "allow_all", + "currentValue": "off" + } + ], + "updateKinds": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "tool_call", + "agent_message_chunk", + "tool_call_update", + "usage_update", + "agent_message_chunk", + "agent_message_chunk" + ], + "stderrTrustHint": false, + "error": null, + "elapsedMs": 9705, + "sessionId": "1ea4f94f-440c-4bbc-87b1-46cff0a1aad2", + "text": "Info: /var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-copilot-trust-cwd-lTC8DY/acp-trust-write.txtDone.", + "toolCalls": [ + { + "sessionUpdate": "tool_call", + "kind": "edit", + "title": "Creating ...de-copilot-trust-cwd-lTC8DY/acp-trust-write.txt" + }, + { + "sessionUpdate": "tool_call_update", + "kind": null, + "title": null + } + ], + "reverseRequestMethods": [] + }, + { + "run": "B", + "description": "--add-dir as today, cwd not in either trust key (tmp cwd)", + "addDir": true, + "seedTrusted": false, + "seedMode": false, + "disallowTempDir": false, + "cwdKind": "tmp", + "cwd": "/var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-copilot-trust-cwd-yanlBL", + "copilotHome": "/Users/admin/.copilot", + "args": [ + "--acp", + "--add-dir", + "/var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-copilot-trust-cwd-yanlBL" + ], + "membership": { + "snakeCount": 0, + "camelCount": 2, + "cwdInSnake": false, + "cwdInCamel": false, + "cwdUnderTrustedAncestor": false, + "tmpUnderTrustedAncestor": false + }, + "opened": true, + "deadlock": false, + "permissionCount": 0, + "permissions": [], + "wrote": "ping", + "stopReason": "end_turn", + "allowAll": "off", + "configOptions": [ + { + "id": "mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent" + }, + { + "id": "allow_all", + "currentValue": "off" + } + ], + "updateKinds": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "tool_call", + "agent_message_chunk", + "tool_call_update", + "usage_update", + "agent_message_chunk", + "agent_message_chunk" + ], + "stderrTrustHint": false, + "error": null, + "elapsedMs": 10300, + "sessionId": "d2a78c24-ecb7-401e-9b5e-312bc92cc149", + "text": "Info: /var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-copilot-trust-cwd-yanlBL/acp-trust-write.txtDone.", + "toolCalls": [ + { + "sessionUpdate": "tool_call", + "kind": "edit", + "title": "Creating ...de-copilot-trust-cwd-yanlBL/acp-trust-write.txt" + }, + { + "sessionUpdate": "tool_call_update", + "kind": null, + "title": null + } + ], + "reverseRequestMethods": [] + }, + { + "run": "C-nested", + "description": "neither (nested git cwd under fixtures)", + "addDir": false, + "seedTrusted": false, + "seedMode": false, + "disallowTempDir": false, + "cwdKind": "nested", + "cwd": "/Users/admin/Projects/ADE/.ade/worktrees/acp-qwen-kimi-copilot-a0f67ad8/apps/desktop/src/main/services/chat/acpHost/fixtures/_trust-cwd-40206-1788185197495", + "copilotHome": "/Users/admin/.copilot", + "args": [ + "--acp" + ], + "membership": { + "snakeCount": 0, + "camelCount": 2, + "cwdInSnake": false, + "cwdInCamel": false, + "cwdUnderTrustedAncestor": false, + "tmpUnderTrustedAncestor": false + }, + "opened": true, + "deadlock": false, + "permissionCount": 0, + "permissions": [], + "wrote": "ping", + "stopReason": "end_turn", + "allowAll": "off", + "configOptions": [ + { + "id": "mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent" + }, + { + "id": "allow_all", + "currentValue": "off" + } + ], + "updateKinds": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "tool_call", + "agent_message_chunk", + "tool_call_update", + "usage_update", + "agent_message_chunk", + "agent_message_chunk" + ], + "stderrTrustHint": false, + "error": null, + "elapsedMs": 10027, + "sessionId": "e8dd402d-ab51-4fc2-ae79-e11422eb85a6", + "text": "Info: /Users/admin/Projects/ADE/.ade/worktrees/acp-qwen-kimi-copilot-a0f67ad8/apps/desktop/src/main/services/chat/acpHost/fixtures/_trust-cwd-40206-1788185197495/acp-trust-write.txtDone.", + "toolCalls": [ + { + "sessionUpdate": "tool_call", + "kind": "edit", + "title": "Creating ...ust-cwd-40206-1788185197495/acp-trust-write.txt" + }, + { + "sessionUpdate": "tool_call_update", + "kind": null, + "title": null + } + ], + "reverseRequestMethods": [] + }, + { + "run": "A-nested", + "description": "trusted_folders snake_case only (nested git cwd)", + "addDir": false, + "seedTrusted": true, + "seedMode": "snake", + "disallowTempDir": false, + "cwdKind": "nested", + "cwd": "/Users/admin/Projects/ADE/.ade/worktrees/acp-qwen-kimi-copilot-a0f67ad8/apps/desktop/src/main/services/chat/acpHost/fixtures/_trust-cwd-40206-1788185207550", + "copilotHome": "/Users/admin/.copilot", + "args": [ + "--acp" + ], + "membership": { + "snakeCount": 1, + "camelCount": 2, + "cwdInSnake": true, + "cwdInCamel": false, + "cwdUnderTrustedAncestor": true, + "tmpUnderTrustedAncestor": false + }, + "opened": true, + "deadlock": false, + "permissionCount": 0, + "permissions": [], + "wrote": "ping", + "stopReason": "end_turn", + "allowAll": "off", + "configOptions": [ + { + "id": "mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent" + }, + { + "id": "allow_all", + "currentValue": "off" + } + ], + "updateKinds": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "tool_call", + "agent_message_chunk", + "tool_call_update", + "usage_update", + "agent_message_chunk", + "agent_message_chunk" + ], + "stderrTrustHint": false, + "error": null, + "elapsedMs": 6720, + "sessionId": "b25c4330-7c5f-4f9a-b715-4d4308165274", + "text": "Info: /Users/admin/Projects/ADE/.ade/worktrees/acp-qwen-kimi-copilot-a0f67ad8/apps/desktop/src/main/services/chat/acpHost/fixtures/_trust-cwd-40206-1788185207550/acp-trust-write.txtDone.", + "toolCalls": [ + { + "sessionUpdate": "tool_call", + "kind": "edit", + "title": "Creating ...ust-cwd-40206-1788185207550/acp-trust-write.txt" + }, + { + "sessionUpdate": "tool_call_update", + "kind": null, + "title": null + } + ], + "reverseRequestMethods": [] + }, + { + "run": "B-nested", + "description": "--add-dir (nested git cwd)", + "addDir": true, + "seedTrusted": false, + "seedMode": false, + "disallowTempDir": false, + "cwdKind": "nested", + "cwd": "/Users/admin/Projects/ADE/.ade/worktrees/acp-qwen-kimi-copilot-a0f67ad8/apps/desktop/src/main/services/chat/acpHost/fixtures/_trust-cwd-40206-1788185214296", + "copilotHome": "/Users/admin/.copilot", + "args": [ + "--acp", + "--add-dir", + "/Users/admin/Projects/ADE/.ade/worktrees/acp-qwen-kimi-copilot-a0f67ad8/apps/desktop/src/main/services/chat/acpHost/fixtures/_trust-cwd-40206-1788185214296" + ], + "membership": { + "snakeCount": 0, + "camelCount": 2, + "cwdInSnake": false, + "cwdInCamel": false, + "cwdUnderTrustedAncestor": false, + "tmpUnderTrustedAncestor": false + }, + "opened": true, + "deadlock": false, + "permissionCount": 0, + "permissions": [], + "wrote": "ping", + "stopReason": "end_turn", + "allowAll": "off", + "configOptions": [ + { + "id": "mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent" + }, + { + "id": "allow_all", + "currentValue": "off" + } + ], + "updateKinds": [ + "available_commands_update", + "available_commands_update", + "session_info_update", + "config_option_update", + "usage_update", + "tool_call", + "agent_message_chunk", + "tool_call_update", + "usage_update", + "agent_message_chunk", + "agent_message_chunk" + ], + "stderrTrustHint": false, + "error": null, + "elapsedMs": 10297, + "sessionId": "b92c0721-51d8-4caf-9d3f-e3f3daf1be44", + "text": "Info: /Users/admin/Projects/ADE/.ade/worktrees/acp-qwen-kimi-copilot-a0f67ad8/apps/desktop/src/main/services/chat/acpHost/fixtures/_trust-cwd-40206-1788185214296/acp-trust-write.txtDone.", + "toolCalls": [ + { + "sessionUpdate": "tool_call", + "kind": "edit", + "title": "Creating ...ust-cwd-40206-1788185214296/acp-trust-write.txt" + }, + { + "sessionUpdate": "tool_call_update", + "kind": null, + "title": null + } + ], + "reverseRequestMethods": [] + } + ] +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/copilotLiveTurn.mjs b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilotLiveTurn.mjs new file mode 100644 index 0000000000..e3441be444 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilotLiveTurn.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** + * Copilot ACP live turn: capture assistant text, cancel, and a write. + * Tiny tmp cwd. Usage: node copilotLiveTurn.mjs + */ +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const PROTOCOL_VERSION = 1; +const CLIENT_INFO = { name: "ade", title: "ADE", version: "1" }; + +class AcpClient { + constructor({ args, cwd }) { + this.pending = new Map(); + this.nextId = 1; + this.updates = []; + this.permissions = []; + this.stderrTail = ""; + this.child = spawn("copilot", args, { + cwd, + env: { ...process.env, NO_COLOR: "1" }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + this.child.stdout?.on("data", this.#onStdout()); + this.child.stderr?.on("data", (chunk) => { + this.stderrTail = `${this.stderrTail}${chunk.toString("utf8")}`.slice(-4_000); + }); + this.child.on("exit", (code, signal) => { + for (const [, waiter] of this.pending) { + waiter.reject(new Error(`copilot exited code=${code} signal=${signal}`)); + } + this.pending.clear(); + }); + } + + #onStdout() { + let buffer = ""; + return (chunk) => { + buffer += chunk.toString("utf8"); + let index = buffer.indexOf("\n"); + while (index !== -1) { + const line = buffer.slice(0, index).replace(/\r$/, ""); + buffer = buffer.slice(index + 1); + if (line.trim()) this.#handleLine(line); + index = buffer.indexOf("\n"); + } + }; + } + + #handleLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return; + let frame; + try { + frame = JSON.parse(trimmed); + } catch { + return; + } + const id = frame.id; + const method = typeof frame.method === "string" ? frame.method : null; + if (method && id !== undefined && id !== null) { + if (method === "session/request_permission") { + this.permissions.push(frame.params ?? null); + const options = Array.isArray(frame.params?.options) ? frame.params.options : []; + const allow = options.find((option) => option.kind === "allow_once") ?? options[0]; + this.#write({ + jsonrpc: "2.0", + id, + result: { + outcome: allow + ? { outcome: "selected", optionId: allow.optionId } + : { outcome: "cancelled" }, + }, + }); + return; + } + this.#write({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `probe does not implement ${method}` }, + }); + return; + } + if (method) { + this.updates.push({ method, params: frame.params ?? null }); + return; + } + if (id === undefined || id === null) return; + const waiter = this.pending.get(id); + if (!waiter) return; + this.pending.delete(id); + if (frame.error) waiter.reject(Object.assign(new Error(frame.error.message ?? "rpc error"), { rpc: frame.error })); + else waiter.resolve(frame.result); + } + + #write(frame) { + if (!this.child.stdin || this.child.stdin.destroyed) return false; + this.child.stdin.write(`${JSON.stringify(frame)}\n`); + return true; + } + + request(method, params, timeoutMs = 60_000) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + this.#write({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) }); + }); + } + + notify(method, params) { + this.#write({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) }); + } + + text() { + return this.updates + .filter((entry) => entry.params?.update?.sessionUpdate === "agent_message_chunk") + .map((entry) => entry.params.update.content?.text ?? "") + .join(""); + } + + updateKinds() { + return this.updates.map((entry) => entry.params?.update?.sessionUpdate ?? entry.method); + } + + configUpdates() { + return this.updates + .filter((entry) => entry.params?.update?.sessionUpdate === "config_option_update") + .map((entry) => entry.params.update); + } + + dispose() { + try { + this.child.stdin?.end(); + } catch { + // gone + } + try { + this.child.kill("SIGTERM"); + } catch { + // gone + } + } +} + +async function openSession(cwd, extraArgs = []) { + const client = new AcpClient({ + args: ["--acp", "--add-dir", cwd, ...extraArgs], + cwd, + }); + await client.request("initialize", { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: CLIENT_INFO, + }, 20_000); + try { + await client.request("authenticate", { methodId: "copilot-login" }, 15_000); + } catch { + // already in + } + const created = await client.request("session/new", { cwd, mcpServers: [] }, 45_000); + return { client, sessionId: created.sessionId }; +} + +async function main() { + const tmp = mkdtempSync(path.join(os.tmpdir(), "ade-copilot-turn-")); + execSync("git init", { cwd: tmp, stdio: "ignore" }); + writeFileSync(path.join(tmp, "README.md"), "probe\n"); + const report = { steps: [] }; + + try { + { + const { client, sessionId } = await openSession(tmp); + try { + const result = await client.request( + "session/prompt", + { sessionId, prompt: [{ type: "text", text: "Reply with exactly the word ping and nothing else. Do not use tools." }] }, + 60_000, + ); + report.steps.push({ + step: "ping", + ok: /ping/i.test(client.text()) && result.stopReason === "end_turn", + text: client.text(), + stopReason: result.stopReason ?? null, + usage: result.usage ?? null, + config: client.configUpdates(), + kinds: client.updateKinds(), + }); + } finally { + client.dispose(); + } + } + + { + const { client, sessionId } = await openSession(tmp, ["--model", "gpt-5.4"]); + try { + const result = await client.request( + "session/prompt", + { sessionId, prompt: [{ type: "text", text: "Reply with exactly the word ping and nothing else. Do not use tools." }] }, + 60_000, + ); + report.steps.push({ + step: "ping-with-model-gpt-5.4", + ok: true, + text: client.text(), + stopReason: result.stopReason ?? null, + usage: result.usage ?? null, + config: client.configUpdates(), + }); + } finally { + client.dispose(); + } + } + + { + const { client, sessionId } = await openSession(tmp); + try { + const prompt = client.request( + "session/prompt", + { + sessionId, + prompt: [{ type: "text", text: "Count slowly from 1 to 80, one integer per line. Do not use tools." }], + }, + 60_000, + ); + await new Promise((resolve) => { + const timer = setInterval(() => { + if (client.text().length > 0) { + clearInterval(timer); + resolve(); + } + }, 40); + setTimeout(() => { + clearInterval(timer); + resolve(); + }, 8_000); + }); + client.notify("session/cancel", { sessionId }); + const result = await prompt; + report.steps.push({ + step: "cancel-mid-prompt", + ok: true, + text: client.text().slice(0, 200), + stopReason: result.stopReason ?? null, + usage: result.usage ?? null, + }); + } finally { + client.dispose(); + } + } + + { + const { client, sessionId } = await openSession(tmp); + try { + const result = await client.request( + "session/prompt", + { + sessionId, + prompt: [{ + type: "text", + text: "Write a file named acp-probe-write.txt in the current working directory containing only the word ping. Use a write tool. Do nothing else.", + }], + }, + 90_000, + ); + let wrote = ""; + try { + wrote = readFileSync(path.join(tmp, "acp-probe-write.txt"), "utf8"); + } catch { + wrote = ""; + } + report.steps.push({ + step: "write-probe", + ok: true, + text: client.text().slice(0, 400), + stopReason: result.stopReason ?? null, + permissionCount: client.permissions.length, + permissionTitles: client.permissions.map((entry) => entry?.toolCall?.title ?? entry?.toolCall?.kind ?? null), + kinds: client.updateKinds(), + wrote, + }); + } finally { + client.dispose(); + } + } + } catch (error) { + report.steps.push({ + step: "fatal", + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch { + // leave tmp + } + } + + writeFileSync(path.join(here, "copilot.live-turn.json"), `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +await main(); diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/copilotModelProbe.mjs b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilotModelProbe.mjs new file mode 100644 index 0000000000..53a709213e --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilotModelProbe.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node +/** + * Copilot model-availability probe. Tries --model on both -p and --acp + * from a tiny tmp cwd. Does not install packages. + * + * Usage: node copilotModelProbe.mjs + */ +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const PING = "Reply with exactly the word ping and nothing else. Do not use tools."; +const PROTOCOL_VERSION = 1; +const CLIENT_INFO = { name: "ade", title: "ADE", version: "1" }; + +class AcpClient { + constructor({ args, cwd }) { + this.pending = new Map(); + this.nextId = 1; + this.notifications = []; + this.stderrTail = ""; + this.exited = null; + this.child = spawn("copilot", args, { + cwd, + env: { ...process.env, NO_COLOR: "1" }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + this.child.stdout?.on("data", this.#onStdout()); + this.child.stderr?.on("data", (chunk) => { + this.stderrTail = `${this.stderrTail}${chunk.toString("utf8")}`.slice(-4_000); + }); + this.child.on("exit", (code, signal) => { + this.exited = { code, signal }; + for (const [, waiter] of this.pending) { + waiter.reject(new Error(`copilot exited code=${code} signal=${signal}`)); + } + this.pending.clear(); + }); + } + + #onStdout() { + let buffer = ""; + return (chunk) => { + buffer += chunk.toString("utf8"); + let index = buffer.indexOf("\n"); + while (index !== -1) { + const line = buffer.slice(0, index).replace(/\r$/, ""); + buffer = buffer.slice(index + 1); + if (line.trim()) this.#handleLine(line); + index = buffer.indexOf("\n"); + } + }; + } + + #handleLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return; + let frame; + try { + frame = JSON.parse(trimmed); + } catch { + return; + } + const id = frame.id; + const method = typeof frame.method === "string" ? frame.method : null; + if (method && id !== undefined && id !== null) { + if (method === "session/request_permission") { + this.#write({ jsonrpc: "2.0", id, result: { outcome: { outcome: "cancelled" } } }); + return; + } + this.#write({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `probe does not implement ${method}` }, + }); + return; + } + if (method) { + this.notifications.push({ method, update: frame.params?.update?.sessionUpdate ?? null }); + return; + } + if (id === undefined || id === null) return; + const waiter = this.pending.get(id); + if (!waiter) return; + this.pending.delete(id); + if (frame.error) waiter.reject(Object.assign(new Error(frame.error.message ?? "rpc error"), { rpc: frame.error })); + else waiter.resolve(frame.result); + } + + #write(frame) { + if (!this.child.stdin || this.child.stdin.destroyed) return false; + this.child.stdin.write(`${JSON.stringify(frame)}\n`); + return true; + } + + request(method, params, timeoutMs = 45_000) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + if (!this.#write({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) })) { + this.pending.delete(id); + clearTimeout(timer); + reject(new Error("stdin is not writable")); + } + }); + } + + notify(method, params) { + this.#write({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) }); + } + + dispose() { + try { + this.child.stdin?.end(); + } catch { + // gone + } + try { + this.child.kill("SIGTERM"); + } catch { + // gone + } + } +} + +function promptText(result, client) { + const chunks = client.notifications + .filter((entry) => entry.update === "agent_message_chunk") + .map((entry) => entry); + const metaText = typeof result?._meta === "object" ? JSON.stringify(result._meta).slice(0, 400) : null; + return { stopReason: result?.stopReason ?? null, metaText, notifications: client.notifications.slice(0, 20) }; +} + +async function probeAcp(cwd, extraArgs) { + const client = new AcpClient({ + args: ["--acp", "--add-dir", cwd, "--no-custom-instructions", ...extraArgs], + cwd, + }); + try { + await client.request("initialize", { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: CLIENT_INFO, + }, 20_000); + try { + await client.request("authenticate", { methodId: "copilot-login" }, 15_000); + } catch { + // already authenticated + } + const created = await client.request("session/new", { cwd, mcpServers: [] }, 45_000); + const result = await client.request( + "session/prompt", + { sessionId: created.sessionId, prompt: [{ type: "text", text: PING }] }, + 60_000, + ); + const text = client.notifications + .filter((entry) => entry.update === "agent_message_chunk"); + return { + ok: true, + extraArgs, + sessionId: created.sessionId, + stopReason: result.stopReason ?? null, + usage: result.usage ?? null, + textHint: JSON.stringify(result).slice(0, 800), + notificationUpdates: client.notifications.map((entry) => entry.update ?? entry.method).slice(0, 30), + stderrTail: client.stderrTail.slice(-800), + }; + } catch (error) { + return { + ok: false, + extraArgs, + error: error instanceof Error ? error.message : String(error), + stderrTail: client.stderrTail.slice(-800), + }; + } finally { + client.dispose(); + } +} + +function probePrompt(cwd, extraArgs) { + const args = [ + "-p", + PING, + "-s", + "--allow-all-tools", + "--add-dir", + cwd, + "--no-custom-instructions", + ...extraArgs, + ]; + try { + const stdout = execSync(`copilot ${args.map((arg) => JSON.stringify(arg)).join(" ")}`, { + cwd, + env: { ...process.env, NO_COLOR: "1" }, + encoding: "utf8", + timeout: 60_000, + stdio: ["ignore", "pipe", "pipe"], + }); + return { ok: true, extraArgs, stdout: stdout.slice(0, 1_200) }; + } catch (error) { + const err = error; + return { + ok: false, + extraArgs, + status: err.status ?? null, + stdout: String(err.stdout ?? "").slice(0, 800), + stderr: String(err.stderr ?? "").slice(0, 800), + }; + } +} + +async function main() { + const tmp = mkdtempSync(path.join(os.tmpdir(), "ade-copilot-probe-")); + execSync("git init", { cwd: tmp, stdio: "ignore" }); + writeFileSync(path.join(tmp, "README.md"), "probe\n"); + const report = { steps: [] }; + try { + report.steps.push({ step: "prompt-default", ...probePrompt(tmp, []) }); + report.steps.push({ step: "prompt-model-auto", ...probePrompt(tmp, ["--model", "auto"]) }); + report.steps.push({ step: "prompt-model-gpt-5.4", ...probePrompt(tmp, ["--model", "gpt-5.4"]) }); + report.steps.push({ step: "acp-default", ...(await probeAcp(tmp, [])) }); + report.steps.push({ step: "acp-model-auto", ...(await probeAcp(tmp, ["--model", "auto"])) }); + report.steps.push({ step: "acp-model-gpt-5.4", ...(await probeAcp(tmp, ["--model", "gpt-5.4"])) }); + report.steps.push({ + step: "acp-model-claude-sonnet-4.6", + ...(await probeAcp(tmp, ["--model", "claude-sonnet-4.6"])), + }); + } finally { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch { + // leave tmp + } + } + writeFileSync(path.join(here, "copilot.model-probe.json"), `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +await main(); diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/copilotTrustGateProbe.mjs b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilotTrustGateProbe.mjs new file mode 100644 index 0000000000..70b878e2eb --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/copilotTrustGateProbe.mjs @@ -0,0 +1,500 @@ +#!/usr/bin/env node +/** + * Copilot trust-gate vs per-tool permission experiment. + * + * Run C first (neither --add-dir nor trusted_folders) so deadlock-avoidance + * is not changed on unproven ground. Tiny throwaway git repo as cwd. + * + * Usage: node copilotTrustGateProbe.mjs + */ +import { spawn, execSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const PROTOCOL_VERSION = 1; +const CLIENT_INFO = { name: "ade", title: "ADE", version: "1" }; +const SESSION_NEW_DEADLOCK_MS = 12_000; +const PROMPT_MS = 90_000; +const WRITE_PROMPT = + "Create a file named acp-trust-write.txt in the current working directory containing only the word ping. Use a file-write or create-file tool, not a shell. Do nothing else."; + +class AcpClient { + constructor({ args, cwd, env }) { + this.pending = new Map(); + this.nextId = 1; + this.updates = []; + this.permissions = []; + this.reverseRequests = []; + this.stderrTail = ""; + this.stdoutNonProtocol = []; + this.exited = null; + this.permissionPolicy = "allow_after_record"; + this.child = spawn("copilot", args, { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + this.pid = this.child.pid ?? null; + this.child.stdout?.on("data", this.#onStdout()); + this.child.stderr?.on("data", (chunk) => { + this.stderrTail = `${this.stderrTail}${chunk.toString("utf8")}`.slice(-6_000); + }); + this.child.on("exit", (code, signal) => { + this.exited = { code, signal }; + for (const [, waiter] of this.pending) { + waiter.reject(new Error(`copilot exited code=${code} signal=${signal}`)); + } + this.pending.clear(); + }); + } + + #onStdout() { + let buffer = ""; + return (chunk) => { + buffer += chunk.toString("utf8"); + let index = buffer.indexOf("\n"); + while (index !== -1) { + const line = buffer.slice(0, index).replace(/\r$/, ""); + buffer = buffer.slice(index + 1); + if (line.trim()) this.#handleLine(line); + index = buffer.indexOf("\n"); + } + }; + } + + #handleLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) { + this.stdoutNonProtocol.push(trimmed.slice(0, 240)); + return; + } + let frame; + try { + frame = JSON.parse(trimmed); + } catch { + this.stdoutNonProtocol.push(trimmed.slice(0, 240)); + return; + } + const id = frame.id; + const method = typeof frame.method === "string" ? frame.method : null; + if (method && id !== undefined && id !== null) { + this.reverseRequests.push({ method, id }); + if (method === "session/request_permission") { + this.permissions.push(summarizePermission(frame.params)); + const outcome = this.#permissionOutcome(frame.params); + this.#write({ jsonrpc: "2.0", id, result: { outcome } }); + return; + } + this.#write({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `probe does not implement ${method}` }, + }); + return; + } + if (method) { + const update = frame.params?.update ?? {}; + this.updates.push({ + method, + sessionUpdate: update.sessionUpdate ?? null, + toolKind: update.kind ?? update.toolCall?.kind ?? null, + title: update.title ?? update.toolCall?.title ?? null, + text: typeof update.content?.text === "string" ? update.content.text : null, + }); + return; + } + if (id === undefined || id === null) return; + const waiter = this.pending.get(id); + if (!waiter) return; + this.pending.delete(id); + if (frame.error) waiter.reject(Object.assign(new Error(frame.error.message ?? "rpc error"), { rpc: frame.error })); + else waiter.resolve(frame.result); + } + + #permissionOutcome(params) { + const options = Array.isArray(params?.options) ? params.options : []; + if (this.permissionPolicy === "hold") { + return { outcome: "cancelled" }; + } + const allow = options.find((option) => option.kind === "allow_once") ?? options[0]; + return allow ? { outcome: "selected", optionId: allow.optionId } : { outcome: "cancelled" }; + } + + #write(frame) { + if (!this.child.stdin || this.child.stdin.destroyed) return false; + this.child.stdin.write(`${JSON.stringify(frame)}\n`); + return true; + } + + request(method, params, timeoutMs) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + if (!this.#write({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) })) { + this.pending.delete(id); + clearTimeout(timer); + reject(new Error("stdin is not writable")); + } + }); + } + + text() { + return this.updates + .filter((entry) => entry.sessionUpdate === "agent_message_chunk" && entry.text) + .map((entry) => entry.text) + .join(""); + } + + dispose() { + try { + this.child.stdin?.end(); + } catch { + // gone + } + try { + this.child.kill("SIGTERM"); + } catch { + // gone + } + setTimeout(() => { + try { + this.child.kill("SIGKILL"); + } catch { + // gone + } + }, 1_200).unref?.(); + } +} + +function summarizePermission(params) { + const toolCall = params?.toolCall ?? {}; + return { + title: toolCall.title ?? null, + kind: toolCall.kind ?? null, + status: toolCall.status ?? null, + optionKinds: Array.isArray(params?.options) ? params.options.map((option) => option.kind ?? null) : [], + }; +} + +function summarizeConfigOptions(options) { + if (!Array.isArray(options)) return []; + return options.map((option) => ({ + id: option.id ?? null, + currentValue: option.currentValue ?? option.value ?? null, + })); +} + +function copilotHome() { + const configured = process.env.COPILOT_HOME?.trim(); + return configured?.length ? path.resolve(configured) : path.join(os.homedir(), ".copilot"); +} + +function parseJsonc(raw) { + const stripped = raw.replace(/^\s*\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); + return JSON.parse(stripped); +} + +function stringFolders(value) { + return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : []; +} + +function withoutPath(folders, cwd) { + const resolved = path.resolve(cwd); + return folders.filter((entry) => path.resolve(entry) !== resolved); +} + +function trustMembership(config, cwd) { + const snake = stringFolders(config.trusted_folders); + const camel = stringFolders(config.trustedFolders); + const resolved = path.resolve(cwd); + const tmp = path.resolve(os.tmpdir()); + const isPrefix = (parent, child) => child === parent || child.startsWith(`${parent}${path.sep}`); + const combined = [...snake, ...camel]; + return { + snakeCount: snake.length, + camelCount: camel.length, + cwdInSnake: snake.some((folder) => path.resolve(folder) === resolved), + cwdInCamel: camel.some((folder) => path.resolve(folder) === resolved), + cwdUnderTrustedAncestor: combined.some((folder) => isPrefix(path.resolve(folder), resolved)), + tmpUnderTrustedAncestor: combined.some((folder) => isPrefix(path.resolve(folder), tmp)), + }; +} + +function makeThrowawayCwd(kind) { + if (kind === "nested") { + const cwd = path.join(here, `_trust-cwd-${process.pid}-${Date.now()}`); + mkdirSync(cwd, { recursive: true }); + execSync("git init", { cwd, stdio: "ignore" }); + writeFileSync(path.join(cwd, "README.md"), "trust-gate probe\n"); + return cwd; + } + const cwd = mkdtempSync(path.join(os.tmpdir(), "ade-copilot-trust-cwd-")); + execSync("git init", { cwd, stdio: "ignore" }); + writeFileSync(path.join(cwd, "README.md"), "trust-gate probe\n"); + return cwd; +} + +function readConfigOriginal(configPath) { + try { + return { existed: true, text: readFileSync(configPath, "utf8") }; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return { existed: false, text: null }; + } + throw error; + } +} + +async function withSeededTrust(cwd, seedMode, fn) { + const home = copilotHome(); + const configPath = path.join(home, "config.json"); + if (!seedMode) { + const original = readConfigOriginal(configPath); + const parsed = original.text ? parseJsonc(original.text) : {}; + return fn({ home, membership: trustMembership(parsed, cwd) }); + } + const original = readConfigOriginal(configPath); + let parsed = {}; + if (original.text) { + parsed = parseJsonc(original.text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${configPath} did not parse as a JSONC object`); + } + } + parsed.trusted_folders = withoutPath(stringFolders(parsed.trusted_folders), cwd); + parsed.trustedFolders = withoutPath(stringFolders(parsed.trustedFolders), cwd); + if (seedMode === "snake") parsed.trusted_folders = [...parsed.trusted_folders, cwd]; + if (seedMode === "camel") parsed.trustedFolders = [...parsed.trustedFolders, cwd]; + if (parsed.trusted_folders.length === 0) delete parsed.trusted_folders; + mkdirSync(home, { recursive: true }); + const commentHeader = original.text?.match(/^(?:\/\/.*\n)*/)?.[0] ?? ""; + writeFileSync(configPath, `${commentHeader}${JSON.stringify(parsed, null, 2)}\n`, "utf8"); + const membership = trustMembership(parsed, cwd); + try { + return await fn({ home, membership }); + } finally { + try { + if (original.existed && original.text != null) { + writeFileSync(configPath, original.text, "utf8"); + } else { + rmSync(configPath, { force: true }); + } + } catch (error) { + process.stderr.write( + `failed to restore ${configPath}: ${error instanceof Error ? error.message : String(error)}\n`, + ); + } + } +} + +async function runCase(spec) { + const cwd = makeThrowawayCwd(spec.cwdKind ?? "tmp"); + try { + return await withSeededTrust(cwd, spec.seedMode ?? false, async ({ home, membership }) => { + const args = ["--acp"]; + if (spec.addDir) args.push("--add-dir", cwd); + if (spec.disallowTempDir) args.push("--disallow-temp-dir"); + const env = { ...process.env, NO_COLOR: "1" }; + delete env.COPILOT_ALLOW_ALL; + const client = new AcpClient({ args, cwd, env }); + const started = Date.now(); + const result = { + run: spec.run, + description: spec.description, + addDir: spec.addDir, + seedTrusted: Boolean(spec.seedMode), + seedMode: spec.seedMode ?? false, + disallowTempDir: Boolean(spec.disallowTempDir), + cwdKind: spec.cwdKind ?? "tmp", + cwd, + copilotHome: home, + args, + membership, + opened: false, + deadlock: false, + permissionCount: 0, + permissions: [], + wrote: null, + stopReason: null, + allowAll: null, + configOptions: [], + updateKinds: [], + stderrTrustHint: false, + error: null, + elapsedMs: 0, + }; + + try { + await client.request( + "initialize", + { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: CLIENT_INFO, + }, + 20_000, + ); + try { + await client.request("authenticate", { methodId: "copilot-login" }, 15_000); + } catch (error) { + result.authError = error instanceof Error ? error.message : String(error); + } + + try { + const created = await client.request("session/new", { cwd, mcpServers: [] }, SESSION_NEW_DEADLOCK_MS); + result.opened = true; + result.sessionId = created.sessionId; + result.configOptions = summarizeConfigOptions(created.configOptions); + result.allowAll = result.configOptions.find((option) => option.id === "allow_all")?.currentValue ?? null; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + result.error = message; + result.deadlock = /timed out/i.test(message) && client.exited == null; + result.stderrTrustHint = /trust/i.test(client.stderrTail); + result.stderrTail = client.stderrTail.slice(-1_200); + result.stdoutNonProtocol = client.stdoutNonProtocol.slice(0, 8); + result.alive = client.exited == null; + return result; + } + + const promptResult = await client.request( + "session/prompt", + { sessionId: result.sessionId, prompt: [{ type: "text", text: WRITE_PROMPT }] }, + PROMPT_MS, + ); + result.stopReason = promptResult.stopReason ?? null; + result.text = client.text().slice(0, 400); + result.permissionCount = client.permissions.length; + result.permissions = client.permissions; + result.updateKinds = client.updates.map((entry) => entry.sessionUpdate ?? entry.method); + result.toolCalls = client.updates + .filter((entry) => entry.sessionUpdate === "tool_call" || entry.sessionUpdate === "tool_call_update") + .map((entry) => ({ sessionUpdate: entry.sessionUpdate, kind: entry.toolKind, title: entry.title })); + result.reverseRequestMethods = client.reverseRequests.map((entry) => entry.method); + try { + result.wrote = readFileSync(path.join(cwd, "acp-trust-write.txt"), "utf8"); + } catch { + result.wrote = null; + } + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + result.permissionCount = client.permissions.length; + result.permissions = client.permissions; + result.updateKinds = client.updates.map((entry) => entry.sessionUpdate ?? entry.method); + result.stderrTail = client.stderrTail.slice(-1_200); + } finally { + result.elapsedMs = Date.now() - started; + client.dispose(); + } + return result; + }); + } finally { + try { + rmSync(cwd, { recursive: true, force: true }); + } catch { + // leave + } + } +} + +async function main() { + const report = { + capturedAt: new Date().toISOString(), + binary: "copilot", + note: "C and B do not touch config.json. A writes JSONC with the original comment header preserved. Previous rewrites of config.json (auth/state file) made every prompt return No model available.", + runs: [], + }; + + report.runs.push( + await runCase({ + run: "C", + description: "neither --add-dir nor trusted folder keys (tmp cwd)", + addDir: false, + seedMode: false, + cwdKind: "tmp", + }), + ); + report.runs.push( + await runCase({ + run: "A", + description: "trusted_folders snake_case only, no --add-dir (tmp cwd)", + addDir: false, + seedMode: "snake", + cwdKind: "tmp", + }), + ); + report.runs.push( + await runCase({ + run: "A-camel", + description: "trustedFolders camelCase only, no --add-dir (tmp cwd)", + addDir: false, + seedMode: "camel", + cwdKind: "tmp", + }), + ); + report.runs.push( + await runCase({ + run: "B", + description: "--add-dir as today, cwd not in either trust key (tmp cwd)", + addDir: true, + seedMode: false, + cwdKind: "tmp", + }), + ); + + const c = report.runs.find((entry) => entry.run === "C"); + const modelWorked = Boolean(c?.opened) && !/no model available/i.test(c?.text ?? ""); + if (c?.opened && modelWorked) { + report.note += + " Run C opened on a tmp cwd; repeating C/A/B on a nested independent git repo under fixtures so system-temp auto-access cannot explain the result."; + report.runs.push( + await runCase({ + run: "C-nested", + description: "neither (nested git cwd under fixtures)", + addDir: false, + seedMode: false, + cwdKind: "nested", + }), + ); + report.runs.push( + await runCase({ + run: "A-nested", + description: "trusted_folders snake_case only (nested git cwd)", + addDir: false, + seedMode: "snake", + cwdKind: "nested", + }), + ); + report.runs.push( + await runCase({ + run: "B-nested", + description: "--add-dir (nested git cwd)", + addDir: true, + seedMode: false, + cwdKind: "nested", + }), + ); + } + + writeFileSync(path.join(here, "copilot.trust-gate.json"), `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +await main(); diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.followup-probe.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.followup-probe.json new file mode 100644 index 0000000000..40315b02a2 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.followup-probe.json @@ -0,0 +1,189 @@ +{ + "steps": [ + { + "step": "cheap-ping", + "ok": true, + "usage": { + "inputTokens": 19364, + "outputTokens": 26, + "cachedReadTokens": 128, + "costUsdTicks": 131465760, + "costUsdNano": 0.13146576, + "stopReason": "end_turn", + "rawMeta": { + "sessionId": "01a056a6-14c4-7452-99d5-108f7fbb8365", + "requestId": "a3968883-8603-46d1-812d-2f6a0ed53971", + "promptId": "a3968883-8603-46d1-812d-2f6a0ed53971", + "totalTokens": 19390, + "modelId": "grok-4.5", + "inputTokens": 19364, + "outputTokens": 26, + "cachedReadTokens": 128, + "reasoningTokens": 25, + "usage": { + "inputTokens": 19364, + "outputTokens": 26, + "totalTokens": 19390, + "cachedReadTokens": 128, + "cacheCreationTokens": 0, + "reasoningTokens": 25, + "modelCalls": 1, + "apiDurationMs": 2075, + "costUsdTicks": 131465760, + "modelUsage": { + "grok-4.5-build": { + "inputTokens": 19364, + "outputTokens": 26, + "totalTokens": 19390, + "cachedReadTokens": 128, + "cacheCreationTokens": 0, + "reasoningTokens": 25, + "modelCalls": 1, + "apiDurationMs": 2075, + "costUsdTicks": 131465760 + } + }, + "numTurns": 1 + } + }, + "text": "ping" + }, + "chunkCount": 1, + "sessionId": "01a056a6-14c4-7452-99d5-108f7fbb8365" + }, + { + "step": "stream-chunks", + "ok": true, + "chunkCount": 15, + "joined": "1\n2\n3\n4\n5\n6\n7\n8", + "updateOrder": [ + "_x.ai/sessions/changed", + "_x.ai/queue/changed", + "_x.ai/queue/changed", + "user_message_chunk", + "_x.ai/models/update", + "_x.ai/announcements/update", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_thought_chunk", + "agent_message_chunk", + "agent_message_chunk", + "agent_message_chunk", + "agent_message_chunk" + ], + "stopReason": "end_turn", + "usage": { + "inputTokens": 20432, + "outputTokens": 52, + "cachedReadTokens": 128, + "costUsdTicks": 69672800, + "costUsdNano": 0.0696728, + "stopReason": "end_turn", + "rawMeta": { + "sessionId": "01a056a6-205b-7233-b6e1-4b4c01cbcf4f", + "requestId": "f2840e72-e448-417f-b25e-daf4aac447e8", + "promptId": "f2840e72-e448-417f-b25e-daf4aac447e8", + "totalTokens": 20486, + "modelId": "grok-4.6", + "inputTokens": 20432, + "outputTokens": 52, + "cachedReadTokens": 128, + "reasoningTokens": 33, + "usage": { + "inputTokens": 20432, + "outputTokens": 52, + "totalTokens": 20484, + "cachedReadTokens": 128, + "cacheCreationTokens": 0, + "reasoningTokens": 33, + "modelCalls": 1, + "apiDurationMs": 2174, + "costUsdTicks": 69672800, + "modelUsage": { + "grok-4.6-build": { + "inputTokens": 20432, + "outputTokens": 52, + "totalTokens": 20484, + "cachedReadTokens": 128, + "cacheCreationTokens": 0, + "reasoningTokens": 33, + "modelCalls": 1, + "apiDurationMs": 2174, + "costUsdTicks": 69672800 + } + }, + "numTurns": 1 + } + } + } + }, + { + "step": "permission-with-empty-claude-config-dir", + "ok": true, + "permissionRequests": 0, + "stopReason": "end_turn", + "claudeConfigDir": "/var/folders/ck/qnm27lyn4d3865_9s0xt26y80000gn/T/ade-grok-probe-8yRrY8/claude-empty", + "note": "A zero here means Grok still auto-allowed even with CLAUDE_CONFIG_DIR pointed at an empty home." + }, + { + "step": "resume-after-close", + "ok": true, + "sessionId": "01a056a6-5386-7e00-b052-1204e09063c7", + "resumeOk": true, + "resumeError": null, + "loadOk": false, + "loadError": null, + "followupText": "beta", + "stopReason": "end_turn" + }, + { + "step": "kill-mid-prompt", + "ok": true, + "promptOutcome": "rejected:grok-kill exited code=143 signal=null", + "exited": { + "code": 143, + "signal": null + }, + "chunksBeforeKill": 5 + }, + { + "step": "two-models-two-pids", + "ok": true, + "first": { + "pid": 40092, + "modelId": "grok-4.6" + }, + "second": { + "pid": 40160, + "modelId": "grok-4.5" + } + } + ] +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.initialize.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.initialize.json new file mode 100644 index 0000000000..4698734150 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.initialize.json @@ -0,0 +1,204 @@ +{ + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "promptCapabilities": { + "image": false, + "audio": false, + "embeddedContext": true + }, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "sessionCapabilities": { + "list": {}, + "resume": {}, + "close": {} + }, + "auth": {}, + "_meta": { + "x.ai/fs_notify": true, + "x.ai/hooks": { + "blockingEvents": [ + "pre_tool_use", + "stop", + "subagent_stop" + ], + "decisions": [ + "deny", + "block" + ], + "stopSignals": [ + "continue", + "stopReason", + "additionalContext" + ] + }, + "x.ai/capabilities": { + "toolOverrides": { + "x_keyword_search": true, + "x_semantic_search": true, + "x_user_search": false, + "x_thread_fetch": false + } + } + } + }, + "authMethods": [ + { + "id": "cached_token", + "name": "cached_token", + "description": "Cached token from ~/.grok/auth.json" + }, + { + "id": "grok.com", + "name": "Grok", + "description": "Sign in with Grok" + } + ], + "_meta": { + "grokShell": true, + "defaultAuthMethodId": "cached_token", + "x.ai/mcp/sdk": true, + "x.ai/pluginDirs": true, + "currentWorkingDirectory": "/Users/admin/Projects/ADE/.ade/worktrees/acp-qwen-kimi-copilot-a0f67ad8/apps/desktop/src/main/services/chat/acpHost/fixtures/live-scratch", + "agentVersion": "1.0.13", + "agentId": "3d41fd5f-8cfa-5711-9ba3-615cb8465a5c", + "agentInstanceId": "2ab713ef-ab00-4f76-b98d-f0b86ffd1ded", + "hostname": "Mac.lan", + "modelState": { + "currentModelId": "grok-4.6", + "availableModels": [ + { + "modelId": "grok-4.6", + "name": "Grok 4.6", + "description": "SpaceXAI's latest frontier model", + "_meta": { + "totalContextTokens": 500000, + "agentType": "grok-build-plan", + "supportsReasoningEffort": true, + "reasoningEffort": "medium", + "reasoningEfforts": [ + { + "id": "xhigh", + "value": "xhigh", + "label": "Extra High Effort", + "description": "Highest effort and reasoning level", + "default": false + }, + { + "id": "high", + "value": "high", + "label": "High Effort", + "description": "Higher implementation quality with extensive reasoning", + "default": true + }, + { + "id": "medium", + "value": "medium", + "label": "Medium Effort", + "description": "Balanced effort with standard implementation and testing", + "default": false + }, + { + "id": "low", + "value": "low", + "label": "Low Effort", + "description": "Quick, fast implementations", + "default": false + } + ] + } + }, + { + "modelId": "grok-4.5", + "name": "Grok 4.5", + "_meta": { + "totalContextTokens": 500000, + "agentType": "grok-build-plan", + "supportsReasoningEffort": true, + "reasoningEffort": "high", + "reasoningEfforts": [ + { + "id": "high", + "value": "high", + "label": "High Effort", + "description": "Highest implementation quality with extensive reasoning", + "default": true + }, + { + "id": "medium", + "value": "medium", + "label": "Medium Effort", + "description": "Balanced effort with standard implementation and testing", + "default": false + }, + { + "id": "low", + "value": "low", + "label": "Low Effort", + "description": "Quick, fast implementations", + "default": false + } + ] + } + } + ] + }, + "mcpServers": [], + "mcpApps": false, + "metadata": null, + "availableCommands": [ + { + "name": "compact", + "description": "Compress conversation history to save context window", + "input": { + "hint": "optional context about what to preserve" + } + }, + { + "name": "always-approve", + "description": "Toggle always-approve mode (skip all permission prompts)", + "input": { + "hint": "on|off" + } + }, + { + "name": "context", + "description": "Show context window usage and session stats", + "input": null + }, + { + "name": "session-info", + "description": "Show session details (model, turns, context usage)", + "input": null + }, + { + "name": "deep-research", + "description": "Research with bounded parallel agents, cross-check evidence, and write a cited report", + "input": { + "hint": "" + } + }, + { + "name": "workflow", + "description": "Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)", + "input": { + "hint": " [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]" + } + }, + { + "name": "goal", + "description": "Set, manage, or check an autonomous goal", + "input": { + "hint": " [--budget ] | status | pause | resume | clear" + } + } + ], + "cancelRewind": true, + "sessionRecap": true, + "feedbackTraceOffer": false, + "voiceMode": true + } +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.permission-probe.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.permission-probe.json new file mode 100644 index 0000000000..02efa36ef6 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.permission-probe.json @@ -0,0 +1,7 @@ +{ + "permissionRequests": 0, + "optionIds": [], + "toolTitles": [], + "wrote": true, + "stderrYolo": false +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.promptResult.meta.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.promptResult.meta.json new file mode 100644 index 0000000000..47d7fbf89f --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.promptResult.meta.json @@ -0,0 +1,36 @@ +{ + "sessionId": "01a0568f-e9a4-7a70-8a81-b1f1ce7f8b95", + "requestId": "4f10edfb-d31a-4155-b57c-62b25520c627", + "promptId": "4f10edfb-d31a-4155-b57c-62b25520c627", + "totalTokens": 29837, + "modelId": "grok-4.6", + "inputTokens": 29805, + "outputTokens": 32, + "cachedReadTokens": 5888, + "reasoningTokens": 27, + "usage": { + "inputTokens": 29805, + "outputTokens": 32, + "totalTokens": 29837, + "cachedReadTokens": 5888, + "cacheCreationTokens": 0, + "reasoningTokens": 27, + "modelCalls": 1, + "apiDurationMs": 2765, + "costUsdTicks": 86649000, + "modelUsage": { + "grok-4.6-build": { + "inputTokens": 29805, + "outputTokens": 32, + "totalTokens": 29837, + "cachedReadTokens": 5888, + "cacheCreationTokens": 0, + "reasoningTokens": 27, + "modelCalls": 1, + "apiDurationMs": 2765, + "costUsdTicks": 86649000 + } + }, + "numTurns": 1 + } +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.shell-permission-probe.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.shell-permission-probe.json new file mode 100644 index 0000000000..99d41f8313 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/grok.shell-permission-probe.json @@ -0,0 +1,5 @@ +{ + "permissionRequests": 0, + "tools": [], + "optionIds": [] +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/grokFollowupProbe.mjs b/apps/desktop/src/main/services/chat/acpHost/fixtures/grokFollowupProbe.mjs new file mode 100644 index 0000000000..374305ad57 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/grokFollowupProbe.mjs @@ -0,0 +1,491 @@ +#!/usr/bin/env node +/** + * Cheap Grok follow-up probes. Cwd is a tiny throwaway repo under os.tmpdir() + * so ADE's worktree is not pulled into the prompt (that was a ~30k-token ping). + * + * Usage: node grokFollowupProbe.mjs + */ +import { spawn } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + writeFileSync, + rmSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const PROTOCOL_VERSION = 1; +const CLIENT_INFO = { name: "ade", title: "ADE", version: "1" }; +const GROK_ARGS = [ + "--no-auto-update", + "--no-plan", + "--permission-mode", + "default", + "agent", + "--no-leader", + "stdio", +]; + +class AcpClient { + constructor({ command, args, cwd, env, label }) { + this.label = label; + this.pending = new Map(); + this.nextId = 1; + this.notifications = []; + this.reverseRequests = []; + this.exited = null; + this.stderrTail = ""; + this.child = spawn(command, args, { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + this.pid = this.child.pid ?? null; + this.child.stdout?.on("data", this.#onStdout()); + this.child.stderr?.on("data", (chunk) => { + this.stderrTail = `${this.stderrTail}${chunk.toString("utf8")}`.slice(-6_000); + }); + this.child.on("exit", (code, signal) => { + this.exited = { code, signal }; + for (const [, waiter] of this.pending) { + waiter.reject(new Error(`${this.label} exited code=${code} signal=${signal}`)); + } + this.pending.clear(); + }); + } + + #onStdout() { + let buffer = ""; + return (chunk) => { + buffer += chunk.toString("utf8"); + let index = buffer.indexOf("\n"); + while (index !== -1) { + const line = buffer.slice(0, index).replace(/\r$/, ""); + buffer = buffer.slice(index + 1); + if (line.trim()) this.#handleLine(line); + index = buffer.indexOf("\n"); + } + }; + } + + #handleLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return; + let frame; + try { + frame = JSON.parse(trimmed); + } catch { + return; + } + const id = frame.id; + const method = typeof frame.method === "string" ? frame.method : null; + if (method && id !== undefined && id !== null) { + this.reverseRequests.push({ method, id, params: frame.params ?? null }); + if (method === "session/request_permission") { + this.#write({ jsonrpc: "2.0", id, result: { outcome: { outcome: "cancelled" } } }); + return; + } + this.#write({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `probe does not implement ${method}` }, + }); + return; + } + if (method) { + this.notifications.push({ method, params: frame.params ?? null }); + return; + } + if (id === undefined || id === null) return; + const waiter = this.pending.get(id); + if (!waiter) return; + this.pending.delete(id); + if (frame.error) waiter.reject(Object.assign(new Error(frame.error.message ?? "rpc error"), { rpc: frame.error })); + else waiter.resolve(frame.result); + } + + #write(frame) { + if (!this.child.stdin || this.child.stdin.destroyed) return false; + this.child.stdin.write(`${JSON.stringify(frame)}\n`); + return true; + } + + request(method, params, timeoutMs = 45_000) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${this.label} ${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + if (!this.#write({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) })) { + this.pending.delete(id); + clearTimeout(timer); + reject(new Error(`${this.label} stdin is not writable`)); + } + }); + } + + notify(method, params) { + this.#write({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) }); + } + + initialize() { + return this.request("initialize", { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: CLIENT_INFO, + _meta: { clientIdentifier: "ade" }, + }, 20_000); + } + + kill(signal = "SIGTERM") { + try { + this.child.kill(signal); + } catch { + // already gone + } + } + + dispose() { + try { + this.child.stdin?.end(); + } catch { + // already gone + } + this.kill("SIGTERM"); + } +} + +function textChunks(client) { + return client.notifications + .filter((entry) => entry.params?.update?.sessionUpdate === "agent_message_chunk") + .map((entry) => entry.params.update.content?.text ?? ""); +} + +function permissionCount(client) { + return client.reverseRequests.filter((entry) => entry.method === "session/request_permission").length; +} + +function usageFromPrompt(result) { + const meta = result?._meta && typeof result._meta === "object" ? result._meta : {}; + const nested = meta.usage && typeof meta.usage === "object" ? meta.usage : {}; + const ticks = nested.costUsdTicks ?? meta.costUsdTicks ?? null; + return { + inputTokens: nested.inputTokens ?? meta.inputTokens ?? null, + outputTokens: nested.outputTokens ?? meta.outputTokens ?? null, + cachedReadTokens: nested.cachedReadTokens ?? meta.cachedReadTokens ?? null, + costUsdTicks: ticks, + costUsdNano: typeof ticks === "number" ? ticks / 1_000_000_000 : null, + stopReason: result?.stopReason ?? null, + rawMeta: meta, + }; +} + +async function withClient(cwd, env, fn) { + const client = new AcpClient({ + command: "grok", + args: GROK_ARGS, + cwd, + env: { ...process.env, NO_COLOR: "1", ...env }, + label: "grok", + }); + try { + await client.initialize(); + const created = await client.request("session/new", { cwd, mcpServers: [] }, 45_000); + return await fn(client, created.sessionId); + } finally { + client.dispose(); + } +} + +async function main() { + const report = { steps: [] }; + const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "ade-grok-probe-")); + const cwd = path.join(tmpRoot, "repo"); + const claudeHome = path.join(tmpRoot, "claude-empty"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(claudeHome, { recursive: true }); + writeFileSync(path.join(claudeHome, "settings.json"), `${JSON.stringify({ permissions: { defaultMode: "default" } }, null, 2)}\n`); + execSync("git init", { cwd, stdio: "ignore" }); + writeFileSync(path.join(cwd, "README.md"), "probe\n"); + + try { + report.steps.push(await (async () => { + const client = new AcpClient({ + command: "grok", + args: GROK_ARGS, + cwd, + env: { ...process.env, NO_COLOR: "1" }, + label: "grok-ping", + }); + try { + await client.initialize(); + const created = await client.request("session/new", { cwd, mcpServers: [] }, 45_000); + const sessionId = created.sessionId; + const result = await client.request( + "session/prompt", + { + sessionId, + prompt: [{ type: "text", text: "Reply with exactly the word ping and nothing else. Do not use tools." }], + }, + 60_000, + ); + const usage = usageFromPrompt(result); + usage.text = textChunks(client).join(""); + try { + await client.request("session/close", { sessionId }, 10_000); + } catch { + // close is best-effort + } + return { step: "cheap-ping", ok: true, usage, chunkCount: textChunks(client).length, sessionId }; + } finally { + client.dispose(); + } + })()); + + report.steps.push(await (async () => { + return withClient(cwd, {}, async (client, sessionId) => { + const before = client.notifications.length; + const result = await client.request( + "session/prompt", + { + sessionId, + prompt: [{ type: "text", text: "Count from 1 to 8 inclusive, one integer per line, nothing else. Do not use tools." }], + }, + 60_000, + ); + const chunks = client.notifications + .slice(before) + .filter((entry) => entry.params?.update?.sessionUpdate === "agent_message_chunk") + .map((entry) => entry.params.update.content?.text ?? ""); + const updates = client.notifications.slice(before).map((entry) => entry.params?.update?.sessionUpdate ?? entry.method); + return { + step: "stream-chunks", + ok: true, + chunkCount: chunks.length, + joined: chunks.join(""), + updateOrder: updates.slice(0, 40), + stopReason: result.stopReason ?? null, + usage: usageFromPrompt(result), + }; + }); + })()); + + report.steps.push(await (async () => { + return withClient(cwd, { CLAUDE_CONFIG_DIR: claudeHome }, async (client, sessionId) => { + writeFileSync(path.join(cwd, "acp-probe-write.txt"), ""); + const result = await client.request( + "session/prompt", + { + sessionId, + prompt: [{ + type: "text", + text: "Overwrite acp-probe-write.txt in the current working directory with only the word ping. Use a write tool. Do nothing else.", + }], + }, + 60_000, + ); + return { + step: "permission-with-empty-claude-config-dir", + ok: true, + permissionRequests: permissionCount(client), + stopReason: result.stopReason ?? null, + claudeConfigDir: claudeHome, + note: "A zero here means Grok still auto-allowed even with CLAUDE_CONFIG_DIR pointed at an empty home.", + }; + }); + })()); + + report.steps.push(await (async () => { + let sessionId = null; + { + const client = new AcpClient({ + command: "grok", + args: GROK_ARGS, + cwd, + env: { ...process.env, NO_COLOR: "1" }, + label: "grok-resume-a", + }); + try { + await client.initialize(); + const created = await client.request("session/new", { cwd, mcpServers: [] }, 45_000); + sessionId = created.sessionId; + await client.request( + "session/prompt", + { + sessionId, + prompt: [{ type: "text", text: "Reply with exactly the word alpha and nothing else. Do not use tools." }], + }, + 60_000, + ); + try { + await client.request("session/close", { sessionId }, 10_000); + } catch { + // close is best-effort + } + } finally { + client.dispose(); + } + } + const client = new AcpClient({ + command: "grok", + args: GROK_ARGS, + cwd, + env: { ...process.env, NO_COLOR: "1" }, + label: "grok-resume-b", + }); + try { + await client.initialize(); + let resumeOk = false; + let resumeError = null; + try { + await client.request("session/resume", { sessionId, cwd, mcpServers: [] }, 45_000); + resumeOk = true; + } catch (error) { + resumeError = error instanceof Error ? error.message : String(error); + } + let loadOk = false; + let loadError = null; + if (!resumeOk) { + try { + await client.request("session/load", { sessionId, cwd, mcpServers: [] }, 45_000); + loadOk = true; + } catch (error) { + loadError = error instanceof Error ? error.message : String(error); + } + } + const result = await client.request( + "session/prompt", + { + sessionId, + prompt: [{ type: "text", text: "Reply with exactly the word beta and nothing else. Do not use tools." }], + }, + 60_000, + ); + return { + step: "resume-after-close", + ok: resumeOk || loadOk, + sessionId, + resumeOk, + resumeError, + loadOk, + loadError, + followupText: textChunks(client).join(""), + stopReason: result.stopReason ?? null, + }; + } finally { + client.dispose(); + } + })()); + + report.steps.push(await (async () => { + const client = new AcpClient({ + command: "grok", + args: GROK_ARGS, + cwd, + env: { ...process.env, NO_COLOR: "1" }, + label: "grok-kill", + }); + try { + await client.initialize(); + const created = await client.request("session/new", { cwd, mcpServers: [] }, 45_000); + const sessionId = created.sessionId; + const prompt = client.request( + "session/prompt", + { + sessionId, + prompt: [{ type: "text", text: "Count slowly from 1 to 200, one integer per line. Do not use tools." }], + }, + 60_000, + ); + await new Promise((resolve) => { + const timer = setInterval(() => { + if (client.notifications.some((entry) => entry.params?.update?.sessionUpdate === "agent_message_chunk")) { + clearInterval(timer); + resolve(); + } + }, 50); + setTimeout(() => { + clearInterval(timer); + resolve(); + }, 8_000); + }); + client.kill("SIGTERM"); + let promptOutcome = "unknown"; + try { + const result = await prompt; + promptOutcome = `resolved:${result.stopReason ?? "none"}`; + } catch (error) { + promptOutcome = `rejected:${error instanceof Error ? error.message : String(error)}`; + } + return { + step: "kill-mid-prompt", + ok: promptOutcome.startsWith("rejected") || promptOutcome.includes("cancelled"), + promptOutcome, + exited: client.exited, + chunksBeforeKill: textChunks(client).length, + }; + } finally { + client.dispose(); + } + })()); + + report.steps.push(await (async () => { + const spawnOne = async (modelId) => { + const client = new AcpClient({ + command: "grok", + args: ["--no-auto-update", "--no-plan", "--permission-mode", "default", "-m", modelId, "agent", "--no-leader", "stdio"], + cwd, + env: { ...process.env, NO_COLOR: "1" }, + label: `grok-${modelId}`, + }); + await client.initialize(); + const created = await client.request("session/new", { cwd, mcpServers: [] }, 45_000); + return { client, pid: client.pid, sessionId: created.sessionId, modelId }; + }; + const first = await spawnOne("grok-4.6"); + const second = await spawnOne("grok-4.5"); + const pidsDiffer = first.pid !== second.pid; + first.client.dispose(); + second.client.dispose(); + return { + step: "two-models-two-pids", + ok: pidsDiffer, + first: { pid: first.pid, modelId: first.modelId }, + second: { pid: second.pid, modelId: second.modelId }, + }; + })()); + } catch (error) { + report.steps.push({ + step: "fatal", + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + try { + rmSync(tmpRoot, { recursive: true, force: true }); + } catch { + // leave the tmp dir if cleanup fails + } + } + + writeFileSync(path.join(here, "grok.followup-probe.json"), `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +await main(); diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/grokPermissionProbe.mjs b/apps/desktop/src/main/services/chat/acpHost/fixtures/grokPermissionProbe.mjs new file mode 100644 index 0000000000..b7da1326ae --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/grokPermissionProbe.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Cheap Grok permission check: spawn with --permission-mode default and ask + * for a write. A permission reverse-RPC means the Claude-settings leak is + * defeated. A write with zero prompts means it is not. + */ +import { spawn } from "node:child_process"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { execSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const cwd = path.join(here, "live-scratch", "isolated-perm"); +rmSync(cwd, { recursive: true, force: true }); +mkdirSync(cwd, { recursive: true }); +writeFileSync(path.join(cwd, "README"), "isolated grok permission probe\n"); +execSync("git init", { cwd, stdio: "ignore" }); + +const child = spawn( + "grok", + ["--no-auto-update", "--no-plan", "--permission-mode", "default", "agent", "--no-leader", "stdio"], + { cwd, env: { ...process.env, NO_COLOR: "1" }, stdio: ["pipe", "pipe", "pipe"] }, +); + +let buffer = ""; +const pending = new Map(); +let nextId = 1; +const permissions = []; +const log = []; + +function send(frame) { + child.stdin.write(`${JSON.stringify(frame)}\n`); +} + +function request(method, params, timeoutMs = 45_000) { + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`${method} timed out`)); + }, timeoutMs); + pending.set(id, { + resolve: (value) => { clearTimeout(timer); resolve(value); }, + reject: (error) => { clearTimeout(timer); reject(error); }, + }); + send({ jsonrpc: "2.0", id, method, params }); + }); +} + +child.stdout.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let index = buffer.indexOf("\n"); + while (index !== -1) { + const line = buffer.slice(0, index).replace(/\r$/, "").trim(); + buffer = buffer.slice(index + 1); + if (line.startsWith("{")) { + try { + const frame = JSON.parse(line); + if (frame.method === "session/request_permission" && frame.id != null) { + permissions.push(frame.params); + const reject = (frame.params?.options ?? []).find((option) => option.kind === "reject_once") + ?? (frame.params?.options ?? [])[0]; + send({ + jsonrpc: "2.0", + id: frame.id, + result: reject + ? { outcome: { outcome: "selected", optionId: reject.optionId } } + : { outcome: { outcome: "cancelled" } }, + }); + } else if (frame.id != null && pending.has(frame.id)) { + const waiter = pending.get(frame.id); + pending.delete(frame.id); + if (frame.error) waiter.reject(Object.assign(new Error(frame.error.message), { rpc: frame.error })); + else waiter.resolve(frame.result); + } + } catch { + log.push(["unparsable", line.slice(0, 200)]); + } + } + index = buffer.indexOf("\n"); + } +}); + +const stderr = []; +child.stderr.on("data", (chunk) => stderr.push(chunk.toString("utf8"))); + +try { + await request("initialize", { + protocolVersion: 1, + clientCapabilities: {}, + clientInfo: { name: "ade", title: "ADE", version: "1" }, + _meta: { clientIdentifier: "ade" }, + }, 20_000); + const created = await request("session/new", { cwd, mcpServers: [] }, 45_000); + await request( + "session/prompt", + { + sessionId: created.sessionId, + prompt: [{ + type: "text", + text: "Create a file named perm-probe.txt in the current directory containing only the word ping. Use a write tool. Do nothing else.", + }], + }, + 90_000, + ); + const wrote = existsSync(path.join(cwd, "perm-probe.txt")); + const report = { + permissionRequests: permissions.length, + optionIds: permissions.flatMap((entry) => (entry.options ?? []).map((option) => option.optionId)), + toolTitles: permissions.map((entry) => entry.toolCall?.title ?? entry.toolCall?.kind ?? null), + wrote, + stderrYolo: stderr.join("").includes("yolo_mode_changed") || stderr.join("").includes("Method not found"), + }; + writeFileSync(path.join(here, "grok.permission-probe.json"), `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} finally { + try { child.kill("SIGTERM"); } catch { /* gone */ } + setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* gone */ } }, 1500).unref?.(); +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/kimi.initialize.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/kimi.initialize.json new file mode 100644 index 0000000000..2f73733876 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/kimi.initialize.json @@ -0,0 +1,57 @@ +{ + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "promptCapabilities": { + "image": true, + "audio": false, + "embeddedContext": true + }, + "sessionCapabilities": { + "list": {}, + "resume": {}, + "close": {}, + "delete": {}, + "fork": {}, + "additionalDirectories": {} + }, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "auth": { + "logout": {} + } + }, + "authMethods": [ + { + "id": "login", + "type": "terminal", + "name": "Login with Kimi account", + "description": "Open the device-code login flow in a terminal.", + "args": [ + "--login" + ], + "env": { + "KIMI_CODE_HOME": "/tmp/ade-kimi-home" + }, + "_meta": { + "terminal-auth": { + "type": "terminal", + "label": "Login with Kimi account", + "command": "kimi", + "args": [ + "login" + ], + "env": { + "KIMI_CODE_HOME": "/tmp/ade-kimi-home" + } + } + } + } + ], + "agentInfo": { + "name": "Kimi Code CLI", + "version": "0.39.1" + } +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/liveBinaryProbe.mjs b/apps/desktop/src/main/services/chat/acpHost/fixtures/liveBinaryProbe.mjs new file mode 100644 index 0000000000..868b93e1d9 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/liveBinaryProbe.mjs @@ -0,0 +1,528 @@ +#!/usr/bin/env node +/** + * Drive a real ACP binary over stdio and record what it actually speaks. + * + * This is verification, not a test harness: it talks to the installed CLI, + * writes the initialize response as a fixture, and exercises session/new, + * prompt, permission, cancel, and close. It never installs packages. + * + * Usage: node liveBinaryProbe.mjs [copilot|grok|all] + */ +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const outputDir = process.env.ACP_LIVE_OUTPUT_DIR?.trim() || mkdtempSync(path.join(tmpdir(), "ade-acp-live-")); +mkdirSync(outputDir, { recursive: true }); +const scratchDir = path.join(outputDir, "live-scratch"); +mkdirSync(scratchDir, { recursive: true }); + +const PROTOCOL_VERSION = 1; +const CLIENT_INFO = { name: "ade", title: "ADE", version: "1" }; +const PING = "Reply with exactly the word ping and nothing else. Do not use tools. Do not read or write files."; +const WRITE_PROBE = + "Create a file named acp-probe-write.txt in the current working directory containing only the word ping. Use a write tool. Do nothing else."; + +export class AcpClient { + constructor({ command, args, cwd, env, label }) { + this.label = label; + this.log = []; + this.pending = new Map(); + this.nextId = 1; + this.notifications = []; + this.reverseRequests = []; + this.permissionPolicy = "reject"; + this.exited = null; + this.stderrTail = ""; + this.child = spawn(command, args, { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + this.pid = this.child.pid ?? null; + this.child.stdout?.on("data", this.#onStdout()); + this.child.stderr?.on("data", (chunk) => { + const text = chunk.toString("utf8"); + this.stderrTail = `${this.stderrTail}${text}`.slice(-8_000); + this.#note("stderr", text.slice(0, 1_000)); + }); + this.child.on("exit", (code, signal) => { + this.exited = { code, signal }; + this.#note("exit", { code, signal }); + for (const [, waiter] of this.pending) { + waiter.reject(new Error(`${this.label} exited code=${code} signal=${signal}`)); + } + this.pending.clear(); + }); + this.child.on("error", (error) => { + this.#note("spawn-error", error.message); + }); + } + + #note(kind, detail) { + this.log.push({ t: Date.now(), kind, detail }); + } + + #onStdout() { + let buffer = ""; + return (chunk) => { + buffer += chunk.toString("utf8"); + let index = buffer.indexOf("\n"); + while (index !== -1) { + const line = buffer.slice(0, index).replace(/\r$/, ""); + buffer = buffer.slice(index + 1); + if (line.trim()) this.#handleLine(line); + index = buffer.indexOf("\n"); + } + }; + } + + #handleLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + this.#note("stdout-non-protocol", trimmed.slice(0, 400)); + return; + } + let frame; + try { + frame = JSON.parse(trimmed); + } catch { + this.#note("stdout-unparsable", trimmed.slice(0, 400)); + return; + } + if (!frame || typeof frame !== "object") return; + + const id = frame.id; + const method = typeof frame.method === "string" ? frame.method : null; + + if (method && id !== undefined && id !== null) { + this.reverseRequests.push({ method, id, params: frame.params ?? null }); + this.#note("reverse-request", { method, id }); + if (method === "session/request_permission") { + const outcome = this.#permissionOutcome(frame.params); + this.#write({ jsonrpc: "2.0", id, result: { outcome } }); + this.#note("permission-answered", outcome); + return; + } + this.#write({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `probe does not implement ${method}` }, + }); + return; + } + + if (method) { + this.notifications.push({ method, params: frame.params ?? null }); + this.#note("notification", { + method, + sessionUpdate: frame.params?.update?.sessionUpdate ?? null, + }); + return; + } + + if (id === undefined || id === null) return; + const waiter = this.pending.get(id); + if (!waiter) { + this.#note("orphan-response", { id }); + return; + } + this.pending.delete(id); + if (frame.error) waiter.reject(Object.assign(new Error(frame.error.message ?? "rpc error"), { rpc: frame.error })); + else waiter.resolve(frame.result); + } + + #permissionOutcome(params) { + const options = Array.isArray(params?.options) ? params.options : []; + if (this.permissionPolicy === "allow") { + const allow = options.find((option) => option.kind === "allow_once") ?? options[0]; + return allow ? { outcome: "selected", optionId: allow.optionId } : { outcome: "cancelled" }; + } + const reject = options.find((option) => option.kind === "reject_once") + ?? options.find((option) => option.kind === "reject_always"); + return reject ? { outcome: "selected", optionId: reject.optionId } : { outcome: "cancelled" }; + } + + #write(frame) { + if (!this.child.stdin || this.child.stdin.destroyed) return false; + this.child.stdin.write(`${JSON.stringify(frame)}\n`); + return true; + } + + request(method, params, timeoutMs = 20_000) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${this.label} ${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + this.#note("request", { id, method }); + if (!this.#write({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) })) { + this.pending.delete(id); + clearTimeout(timer); + reject(new Error(`${this.label} stdin is not writable`)); + } + }); + } + + notify(method, params) { + this.#note("notify", { method }); + this.#write({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) }); + } + + async initialize(extra = {}) { + return this.request("initialize", { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: CLIENT_INFO, + ...extra, + }, 20_000); + } + + dispose() { + try { + this.child.stdin?.end(); + } catch { + // already gone + } + try { + this.child.kill("SIGTERM"); + } catch { + // already gone + } + setTimeout(() => { + try { + this.child.kill("SIGKILL"); + } catch { + // already gone + } + }, 1_500).unref?.(); + } +} + +export function errInfo(error) { + return { + message: error instanceof Error ? error.message : String(error), + rpc: error?.rpc ?? null, + }; +} + +export function summarizeCaps(init) { + const caps = init?.agentCapabilities ?? {}; + return { + protocolVersion: init?.protocolVersion ?? null, + agentInfo: init?.agentInfo ?? null, + authMethods: (init?.authMethods ?? []).map((method) => ({ + id: method.id, + name: method.name, + type: method.type ?? null, + })), + loadSession: caps.loadSession === true, + prompt: caps.promptCapabilities ?? null, + mcp: caps.mcpCapabilities ?? null, + session: caps.sessionCapabilities ?? null, + _meta: init?._meta ?? null, + }; +} + +async function probeCopilot() { + const report = { provider: "copilot", binary: "copilot", version: "1.0.82", steps: [] }; + const client = new AcpClient({ + command: "copilot", + args: ["--acp", "--add-dir", scratchDir, "--no-auto-update"], + cwd: scratchDir, + env: { ...process.env, NO_COLOR: "1" }, + label: "copilot", + }); + + try { + const init = await client.initialize(); + writeFileSync(path.join(here, "copilot.initialize.json"), `${JSON.stringify(init, null, 2)}\n`); + report.steps.push({ step: "initialize", ok: true, caps: summarizeCaps(init) }); + + const authMethods = init.authMethods ?? []; + if (authMethods.length) { + const methodId = authMethods[0].id; + if (authMethods[0].type === "terminal") { + report.steps.push({ + step: "authenticate", + ok: false, + skipped: true, + reason: "terminal login method advertised — treating as not signed in over ACP", + methodId, + }); + } else { + try { + const auth = await client.request("authenticate", { methodId }, 15_000); + report.steps.push({ step: "authenticate", ok: true, methodId, result: auth ?? null }); + } catch (error) { + report.steps.push({ step: "authenticate", ok: false, methodId, ...errInfo(error) }); + } + } + } else { + report.steps.push({ step: "authenticate", ok: true, skipped: true, reason: "no authMethods advertised" }); + } + + const created = await client.request("session/new", { cwd: scratchDir, mcpServers: [] }, 45_000); + report.steps.push({ step: "session/new", ok: true, sessionId: created.sessionId, modes: created.modes ?? null }); + const sessionId = created.sessionId; + + client.permissionPolicy = "reject"; + try { + const prompt = await client.request( + "session/prompt", + { sessionId, prompt: [{ type: "text", text: PING }] }, + 90_000, + ); + const textChunks = client.notifications + .filter((entry) => entry.params?.update?.sessionUpdate === "agent_message_chunk") + .map((entry) => entry.params.update.content?.text ?? "") + .join(""); + report.steps.push({ + step: "session/prompt ping", + ok: true, + stopReason: prompt.stopReason ?? null, + usage: prompt.usage ?? null, + _meta: prompt._meta ?? null, + text: textChunks.slice(0, 400), + permissions: client.reverseRequests.filter((entry) => entry.method === "session/request_permission").length, + }); + } catch (error) { + report.steps.push({ step: "session/prompt ping", ok: false, ...errInfo(error) }); + } + + const cancelSession = await client.request("session/new", { cwd: scratchDir, mcpServers: [] }, 45_000); + const cancelId = cancelSession.sessionId; + const promptPromise = client.request( + "session/prompt", + { + sessionId: cancelId, + prompt: [{ type: "text", text: "Count slowly from 1 to 200 in words. Do not use tools." }], + }, + 90_000, + ); + await new Promise((resolve) => setTimeout(resolve, 800)); + let cancelAsRequest = null; + try { + cancelAsRequest = await client.request("session/cancel", { sessionId: cancelId }, 10_000); + } catch (error) { + cancelAsRequest = { error: errInfo(error) }; + } + let promptAfterCancel = null; + try { + promptAfterCancel = await promptPromise; + } catch (error) { + promptAfterCancel = { error: errInfo(error) }; + } + report.steps.push({ + step: "cancel-during-prompt", + ok: true, + cancelAsRequest, + promptResult: promptAfterCancel, + note: "Copilot is known to report stopReason end_turn after cancel (github/copilot-cli#4561)", + }); + + try { + await client.request("session/close", { sessionId }, 10_000); + report.steps.push({ step: "session/close", ok: true }); + } catch (error) { + report.steps.push({ step: "session/close", ok: false, ...errInfo(error) }); + } + try { + await client.request("session/close", { sessionId: cancelId }, 10_000); + } catch { + // best effort + } + } catch (error) { + report.steps.push({ step: "fatal", ok: false, ...errInfo(error), stderrTail: client.stderrTail.slice(-1_500) }); + } finally { + client.dispose(); + report.stderrTail = client.stderrTail.slice(-1_500); + report.notificationsSample = client.notifications.slice(0, 30).map((entry) => ({ + method: entry.method, + sessionUpdate: entry.params?.update?.sessionUpdate ?? null, + })); + report.permissionRequests = client.reverseRequests + .filter((entry) => entry.method === "session/request_permission") + .map((entry) => ({ + tool: entry.params?.toolCall?.title ?? entry.params?.toolCall?.kind ?? null, + options: (entry.params?.options ?? []).map((option) => ({ id: option.optionId, kind: option.kind, name: option.name })), + })); + } + return report; +} + +async function probeGrok() { + const report = { provider: "grok", binary: "grok", version: "1.0.13", steps: [] }; + const client = new AcpClient({ + command: "grok", + args: ["--no-auto-update", "--no-plan", "agent", "--no-leader", "stdio"], + cwd: scratchDir, + env: { ...process.env, NO_COLOR: "1" }, + label: "grok", + }); + + try { + const init = await client.initialize({ _meta: { clientIdentifier: "ade" } }); + writeFileSync(path.join(here, "grok.initialize.json"), `${JSON.stringify(init, null, 2)}\n`); + report.steps.push({ step: "initialize", ok: true, caps: summarizeCaps(init) }); + + const created = await client.request("session/new", { cwd: scratchDir, mcpServers: [] }, 45_000); + report.steps.push({ step: "session/new", ok: true, sessionId: created.sessionId, modes: created.modes ?? null }); + const sessionId = created.sessionId; + + client.notify("x.ai/yolo_mode_changed", { sessionId, auto_mode: false, permission_mode: "ask" }); + report.steps.push({ step: "yolo_mode_changed", ok: true, sentAfterSessionNew: true }); + + try { + const asRequest = await client.request("session/cancel", { sessionId }, 8_000); + report.steps.push({ + step: "cancel-as-request", + ok: false, + unexpectedSuccess: asRequest, + expected: "-32601 method not found", + }); + } catch (error) { + const code = error?.rpc?.code ?? null; + report.steps.push({ + step: "cancel-as-request", + ok: code === -32601, + expectedCode: -32601, + ...errInfo(error), + }); + } + + client.permissionPolicy = "reject"; + try { + const prompt = await client.request( + "session/prompt", + { sessionId, prompt: [{ type: "text", text: PING }] }, + 90_000, + ); + const textChunks = client.notifications + .filter((entry) => entry.params?.update?.sessionUpdate === "agent_message_chunk") + .map((entry) => entry.params.update.content?.text ?? "") + .join(""); + report.steps.push({ + step: "session/prompt ping", + ok: true, + stopReason: prompt.stopReason ?? null, + usage: prompt.usage ?? null, + _meta: prompt._meta ?? null, + text: textChunks.slice(0, 400), + }); + } catch (error) { + report.steps.push({ step: "session/prompt ping", ok: false, ...errInfo(error) }); + } + + const permBefore = client.reverseRequests.filter((entry) => entry.method === "session/request_permission").length; + try { + const writePrompt = await client.request( + "session/prompt", + { sessionId, prompt: [{ type: "text", text: WRITE_PROBE }] }, + 90_000, + ); + const permAfter = client.reverseRequests.filter((entry) => entry.method === "session/request_permission").length; + report.steps.push({ + step: "permission-write-probe", + ok: true, + stopReason: writePrompt.stopReason ?? null, + permissionRequests: permAfter - permBefore, + permissionOptionIds: client.reverseRequests + .filter((entry) => entry.method === "session/request_permission") + .flatMap((entry) => (entry.params?.options ?? []).map((option) => option.optionId)), + note: "A zero here with a written file means Grok auto-allowed (Claude defaultMode leak). A request means permissions prompted.", + }); + } catch (error) { + const permAfter = client.reverseRequests.filter((entry) => entry.method === "session/request_permission").length; + report.steps.push({ + step: "permission-write-probe", + ok: false, + permissionRequests: permAfter - permBefore, + ...errInfo(error), + }); + } + + const cancelSession = await client.request("session/new", { cwd: scratchDir, mcpServers: [] }, 45_000); + const cancelId = cancelSession.sessionId; + client.notify("x.ai/yolo_mode_changed", { sessionId: cancelId, auto_mode: false, permission_mode: "ask" }); + const promptPromise = client.request( + "session/prompt", + { + sessionId: cancelId, + prompt: [{ type: "text", text: "Count slowly from 1 to 200 in words. Do not use tools." }], + }, + 90_000, + ); + await new Promise((resolve) => setTimeout(resolve, 800)); + client.notify("session/cancel", { sessionId: cancelId }); + let promptAfterCancel = null; + try { + promptAfterCancel = await promptPromise; + } catch (error) { + promptAfterCancel = { error: errInfo(error) }; + } + report.steps.push({ + step: "cancel-as-notification", + ok: true, + promptResult: promptAfterCancel, + }); + + try { + await client.request("session/close", { sessionId }, 10_000); + report.steps.push({ step: "session/close", ok: true }); + } catch (error) { + report.steps.push({ step: "session/close", ok: false, ...errInfo(error) }); + } + try { + await client.request("session/close", { sessionId: cancelId }, 10_000); + } catch { + // best effort + } + } catch (error) { + report.steps.push({ step: "fatal", ok: false, ...errInfo(error), stderrTail: client.stderrTail.slice(-1_500) }); + } finally { + client.dispose(); + report.stderrTail = client.stderrTail.slice(-1_500); + report.spinnerHints = client.notifications.filter((entry) => entry.method === "x.ai/session_notification").length; + report.permissionRequests = client.reverseRequests + .filter((entry) => entry.method === "session/request_permission") + .map((entry) => ({ + tool: entry.params?.toolCall?.title ?? entry.params?.toolCall?.kind ?? null, + options: (entry.params?.options ?? []).map((option) => ({ id: option.optionId, kind: option.kind, name: option.name })), + })); + } + return report; +} + +const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isDirectRun) { + const target = process.argv[2] ?? "all"; + const reports = {}; + if (target === "all" || target === "copilot") { + process.stderr.write("probing copilot...\n"); + reports.copilot = await probeCopilot(); + } + if (target === "all" || target === "grok") { + process.stderr.write("probing grok...\n"); + reports.grok = await probeGrok(); + } + writeFileSync(path.join(outputDir, "live-probe-report.json"), `${JSON.stringify(reports, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(reports, null, 2)}\n`); + const failed = Object.values(reports).some((report) => report.steps.some((step) => step.step === "fatal")); + process.exit(failed ? 1 : 0); +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/qwen-kimi-close-probe.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/qwen-kimi-close-probe.json new file mode 100644 index 0000000000..4aedf641e6 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/qwen-kimi-close-probe.json @@ -0,0 +1,63 @@ +{ + "qwen": { + "provider": "qwen", + "steps": [ + { + "step": "initialize", + "ok": true, + "closeAdvertised": false, + "sessionCapabilities": { + "list": {}, + "resume": {} + } + }, + { + "step": "session/close dummy id", + "ok": false, + "message": "\"Method not found\": session/close", + "rpc": { + "code": -32601, + "message": "\"Method not found\": session/close", + "data": { + "method": "session/close" + } + } + }, + { + "step": "authenticate openai", + "ok": false, + "message": "Internal error", + "rpc": { + "code": -32603, + "message": "Internal error", + "data": { + "details": "Missing API key for openai auth. Current model: 'coder-model', baseUrl: '(default)'. Provide an API key via settings (security.auth.apiKey), or set the environment variable 'OPENAI_API_KEY'." + } + } + } + ] + }, + "kimi": { + "provider": "kimi", + "steps": [ + { + "step": "initialize", + "ok": true, + "closeAdvertised": true, + "sessionCapabilities": { + "list": {}, + "resume": {}, + "close": {}, + "delete": {}, + "fork": {}, + "additionalDirectories": {} + } + }, + { + "step": "session/close dummy id", + "ok": true, + "unexpected": {} + } + ] + } +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/qwen.initialize.json b/apps/desktop/src/main/services/chat/acpHost/fixtures/qwen.initialize.json new file mode 100644 index 0000000000..c8e9169559 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/qwen.initialize.json @@ -0,0 +1,44 @@ +{ + "protocolVersion": 1, + "agentInfo": { + "name": "qwen-code", + "title": "Qwen Code", + "version": "0.22.3" + }, + "authMethods": [ + { + "id": "openai", + "name": "Use OpenAI API key", + "description": "Requires setting the `OPENAI_API_KEY` environment variable", + "_meta": { + "type": "terminal", + "args": [ + "--auth-type=openai" + ] + } + } + ], + "agentCapabilities": { + "loadSession": true, + "promptCapabilities": { + "image": true, + "audio": true, + "embeddedContext": true + }, + "sessionCapabilities": { + "list": {}, + "resume": {} + }, + "mcpCapabilities": { + "sse": true, + "http": true + }, + "_meta": { + "imageCapability": { + "autoHandlesWrongModel": true, + "maxBytes": 10380902, + "maxImagesPerTurn": 4 + } + } + } +} diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/qwenKimiCloseProbe.mjs b/apps/desktop/src/main/services/chat/acpHost/fixtures/qwenKimiCloseProbe.mjs new file mode 100644 index 0000000000..f914565806 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/qwenKimiCloseProbe.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Dummy session/close after initialize — no auth, no prompt. + * Distinguishes "method exists" from "not implemented". + */ +import os from "node:os"; +import path from "node:path"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; +import { AcpClient, errInfo } from "./liveBinaryProbe.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +function isolateEnv(overrides = {}) { + const env = { ...process.env, NO_COLOR: "1", ...overrides }; + for (const key of [ + "OPENAI_API_KEY", "OPENAI_BASE_URL", "DASHSCOPE_API_KEY", "QWEN_API_KEY", + "MOONSHOT_API_KEY", "KIMI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", + ]) delete env[key]; + return env; +} + +async function probe(label, command, args, env, cwd) { + const client = new AcpClient({ command, args, cwd, env, label }); + const steps = []; + try { + const init = await client.initialize(); + steps.push({ + step: "initialize", + ok: true, + closeAdvertised: Boolean(init?.agentCapabilities?.sessionCapabilities && Object.prototype.hasOwnProperty.call(init.agentCapabilities.sessionCapabilities, "close")), + sessionCapabilities: init?.agentCapabilities?.sessionCapabilities ?? null, + }); + try { + const result = await client.request("session/close", { sessionId: "00000000-0000-4000-8000-000000000000" }, 8_000); + steps.push({ step: "session/close dummy id", ok: true, unexpected: result }); + } catch (error) { + steps.push({ step: "session/close dummy id", ok: false, ...errInfo(error) }); + } + if (label === "qwen") { + try { + const auth = await client.request("authenticate", { methodId: "openai" }, 8_000); + steps.push({ step: "authenticate openai", ok: true, result: auth ?? null }); + } catch (error) { + steps.push({ step: "authenticate openai", ok: false, ...errInfo(error) }); + } + } + } catch (error) { + steps.push({ step: "fatal", ok: false, ...errInfo(error) }); + } finally { + client.dispose(); + } + return { provider: label, steps }; +} + +const root = mkdtempSync(path.join(os.tmpdir(), "ade-close-probe-")); +const cwd = path.join(root, "repo"); +mkdirSync(cwd, { recursive: true }); +execSync("git init -q", { cwd }); +writeFileSync(path.join(cwd, "README.md"), "probe\n"); +const fakeHome = path.join(root, "home"); +mkdirSync(fakeHome, { recursive: true }); +const env = isolateEnv({ + HOME: fakeHome, + PATH: `${path.join(os.homedir(), ".kimi-code", "bin")}:${process.env.PATH ?? ""}`, + QWEN_HOME: path.join(root, "qwen-home"), + KIMI_CODE_HOME: path.join(root, "kimi-home"), +}); +mkdirSync(env.QWEN_HOME, { recursive: true }); +mkdirSync(env.KIMI_CODE_HOME, { recursive: true }); + +const report = { + qwen: await probe("qwen", "qwen", ["--acp"], env, cwd), + kimi: await probe("kimi", "kimi", ["acp"], env, cwd), +}; +writeFileSync(path.join(here, "qwen-kimi-close-probe.json"), `${JSON.stringify(report, null, 2)}\n`); +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/apps/desktop/src/main/services/chat/acpHost/fixtures/qwenKimiUnauthProbe.mjs b/apps/desktop/src/main/services/chat/acpHost/fixtures/qwenKimiUnauthProbe.mjs new file mode 100644 index 0000000000..f0d469dcf8 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/fixtures/qwenKimiUnauthProbe.mjs @@ -0,0 +1,342 @@ +#!/usr/bin/env node +/** + * Unauthenticated Qwen + Kimi verification. + * + * Config homes and scratch cwds live under os.tmpdir() only. Does not write + * ~/.qwen, ~/.kimi-code (beyond the installer), ~/.claude, ~/.grok, or + * ~/.copilot. Does not run login or spend API keys. + * + * Usage: node qwenKimiUnauthProbe.mjs + */ +import { spawn, spawnSync, execSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { AcpClient, errInfo, summarizeCaps } from "./liveBinaryProbe.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; +const STRIP_ENV_KEYS = [ + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "DASHSCOPE_API_KEY", + "QWEN_API_KEY", + "MOONSHOT_API_KEY", + "KIMI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", +]; + +function isolateEnv(overrides = {}) { + const env = { ...process.env, NO_COLOR: "1", ...overrides }; + for (const key of STRIP_ENV_KEYS) delete env[key]; + return env; +} + +function capture(command, args, env, timeoutMs = 15_000) { + const result = spawnSync(command, args, { + encoding: "utf8", + env, + timeout: timeoutMs, + windowsHide: true, + }); + return { + status: result.status, + signal: result.signal ?? null, + stdout: (result.stdout ?? "").slice(0, 8_000), + stderr: (result.stderr ?? "").slice(0, 8_000), + error: result.error ? result.error.message : null, + }; +} + +function firstLine(text) { + return (text ?? "").split(/\r?\n/).find((line) => line.trim()) ?? ""; +} + +function listRel(dir) { + if (!existsSync(dir)) return { exists: false, entries: [] }; + const entries = []; + const walk = (current, prefix = "") => { + for (const name of readdirSync(current)) { + const full = path.join(current, name); + const rel = prefix ? `${prefix}/${name}` : name; + try { + const st = statSync(full); + if (st.isDirectory()) walk(full, rel); + else entries.push({ path: rel, bytes: st.size }); + } catch { + entries.push({ path: rel, bytes: null }); + } + } + }; + walk(dir); + return { exists: true, entries }; +} + +function makeGitCwd(parent) { + const cwd = path.join(parent, "repo"); + mkdirSync(cwd, { recursive: true }); + execSync("git init -q", { cwd }); + writeFileSync(path.join(cwd, "README.md"), "ade acp unauth probe\n"); + return cwd; +} + +async function waitMs(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function spawnUntil(command, args, env, ms) { + const child = spawn(command, args, { env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + return new Promise((resolve) => { + const timer = setTimeout(() => { + try { + child.kill("SIGTERM"); + } catch { + // already gone + } + setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // already gone + } + }, 500); + }, ms); + child.on("exit", (code, signal) => { + clearTimeout(timer); + resolve({ + status: code, + signal, + stdout: stdout.slice(0, 4_000), + stderr: stderr.slice(0, 4_000), + timedOut: signal === "SIGTERM" || signal === "SIGKILL", + }); + }); + child.on("error", (error) => { + clearTimeout(timer); + resolve({ status: null, signal: null, stdout, stderr, error: error.message, timedOut: false }); + }); + }); +} + +function qwenCliChecks(env) { + const help = capture("qwen", ["--help"], env); + const fullHelp = capture("qwen", ["--yolo", "--approval-mode=default", "-p", "ping"], env); + const combined = `${help.stdout}\n${help.stderr}`; + const full = `${fullHelp.stdout}\n${fullHelp.stderr}`; + const presentInDefaultHelp = { + "-i": / -i, --prompt-interactive/.test(combined), + "-m": / -m, --model/.test(combined), + "--approval-mode": /--approval-mode/.test(combined), + "--session-id": /--session-id/.test(combined), + "--resume": / -r, --resume/.test(combined), + "--continue": / -c, --continue/.test(combined), + "--yolo": / -y, --yolo/.test(combined), + "--append-system-prompt": /--append-system-prompt/.test(combined), + "--acp": /--acp/.test(combined), + }; + const presentInErrorHelp = { + "-i": / -i, --prompt-interactive/.test(full), + "-m": / -m, --model/.test(full), + "--approval-mode": /--approval-mode/.test(full), + "--session-id": /--session-id/.test(full), + "--resume": / -r, --resume/.test(full), + "--continue": / -c, --continue/.test(full), + "--yolo": / -y, --yolo/.test(full), + "--append-system-prompt": /--append-system-prompt/.test(full), + "--acp": /--acp/.test(full), + approvalChoices: /choices: "plan", "default", "auto-edit", "auto", "yolo"/.test(full), + }; + return { + version: firstLine(capture("qwen", ["--version"], env).stdout), + defaultHelpOmitsLoadBearingFlags: Object.entries(presentInDefaultHelp) + .filter(([, ok]) => !ok) + .map(([flag]) => flag), + errorHelpFlags: presentInErrorHelp, + yoloPlusApproval: { + status: fullHelp.status, + firstLine: firstLine(full), + expected: "Cannot use both --yolo (-y) and --approval-mode together", + matched: /Cannot use both --yolo/.test(full), + }, + sessionIdPlusResume: capture( + "qwen", + ["--session-id", UUID, "--resume", UUID, "-p", "ping"], + env, + ), + sessionIdPlusContinue: capture( + "qwen", + ["--session-id", UUID, "--continue", "-p", "ping"], + env, + ), + approvalModeBogus: capture("qwen", ["--approval-mode", "bogus", "-p", "ping"], env), + authSubcommand: capture("qwen", ["auth", "--help"], env), + }; +} + +function kimiCliChecks(env) { + const help = capture("kimi", ["--help"], env); + const text = `${help.stdout}\n${help.stderr}`; + const acpHelp = capture("kimi", ["acp", "--help"], env); + const loginHelp = capture("kimi", ["login", "--help"], env); + return { + version: firstLine(capture("kimi", ["--version"], env).stdout), + flags: { + noPositionalPrompt: !/\[prompt\]|\[query\]/.test(text) && /\[options\] \[command\]/.test(text), + sessionDashS: / -S, --session/.test(text), + continueDashC: / -c, --continue/.test(text), + yolo: / -y, --yolo/.test(text), + auto: /--auto/.test(text), + plan: /--plan/.test(text), + modelAlias: /LLM model alias/.test(text), + addDir: /--add-dir/.test(text), + promptNonInteractive: / -p, --prompt/.test(text), + }, + yoloPlusAutoPrompt: capture("kimi", ["--yolo", "--auto", "-p", "ping"], env), + yoloPlusPrompt: capture("kimi", ["--yolo", "-p", "ping"], env), + autoPlusPrompt: capture("kimi", ["--auto", "-p", "ping"], env), + yoloPlusAutoDoctor: capture("kimi", ["--yolo", "--auto", "doctor"], env), + acpHelp: `${acpHelp.stdout}\n${acpHelp.stderr}`.slice(0, 2_000), + loginHelp: `${loginHelp.stdout}\n${loginHelp.stderr}`.slice(0, 2_000), + }; +} + +async function probeAcp({ label, command, args, cwd, env, fixtureName }) { + const report = { provider: label, command, args, steps: [] }; + const client = new AcpClient({ command, args, cwd, env, label }); + try { + const init = await client.initialize(); + writeFileSync(path.join(here, fixtureName), `${JSON.stringify(init, null, 2)}\n`); + report.steps.push({ step: "initialize", ok: true, caps: summarizeCaps(init), fixture: fixtureName }); + + try { + const created = await client.request("session/new", { cwd, mcpServers: [] }, 30_000); + report.steps.push({ + step: "session/new", + ok: true, + unexpectedUnauthenticatedSession: true, + sessionId: created.sessionId ?? null, + modes: created.modes ?? null, + configOptions: created.configOptions ?? null, + }); + try { + await client.request("session/close", { sessionId: created.sessionId }, 8_000); + report.steps.push({ step: "session/close", ok: true }); + } catch (error) { + report.steps.push({ step: "session/close", ok: false, ...errInfo(error) }); + } + } catch (error) { + report.steps.push({ + step: "session/new", + ok: false, + expectedAuthError: true, + ...errInfo(error), + }); + } + } catch (error) { + report.steps.push({ step: "fatal", ok: false, ...errInfo(error), stderrTail: client.stderrTail.slice(-2_000) }); + } finally { + client.dispose(); + report.stderrTail = client.stderrTail.slice(-2_000); + report.notificationsSample = client.notifications.slice(0, 20).map((entry) => ({ + method: entry.method, + sessionUpdate: entry.params?.update?.sessionUpdate ?? null, + })); + } + return report; +} + +async function main() { + const root = mkdtempSync(path.join(os.tmpdir(), "ade-qwen-kimi-")); + const fakeHome = path.join(root, "home"); + const qwenHome = path.join(root, "qwen-home"); + const kimiHome = path.join(root, "kimi-home"); + const scratch = makeGitCwd(root); + mkdirSync(fakeHome, { recursive: true }); + mkdirSync(qwenHome, { recursive: true }); + mkdirSync(kimiHome, { recursive: true }); + + const kimiBin = path.join(os.homedir(), ".kimi-code", "bin"); + const baseEnv = isolateEnv({ + HOME: fakeHome, + PATH: `${kimiBin}:${process.env.PATH ?? ""}`, + QWEN_HOME: qwenHome, + KIMI_CODE_HOME: kimiHome, + }); + + const report = { + tmpRoot: root, + versions: { + qwen: firstLine(capture("qwen", ["--version"], baseEnv).stdout), + kimi: firstLine(capture("kimi", ["--version"], baseEnv).stdout), + }, + cli: {}, + acp: {}, + configHomes: {}, + kimiYoloAutoTui: null, + }; + + process.stderr.write("cli checks...\n"); + report.cli.qwen = qwenCliChecks(baseEnv); + report.cli.kimi = kimiCliChecks(baseEnv); + + process.stderr.write("kimi --yolo --auto tui (2s)...\n"); + report.kimiYoloAutoTui = await spawnUntil("kimi", ["--yolo", "--auto"], baseEnv, 2_000); + + process.stderr.write("qwen acp handshake...\n"); + report.acp.qwen = await probeAcp({ + label: "qwen", + command: "qwen", + args: ["--acp"], + cwd: scratch, + env: baseEnv, + fixtureName: "qwen.initialize.json", + }); + + process.stderr.write("kimi acp handshake...\n"); + report.acp.kimi = await probeAcp({ + label: "kimi", + command: "kimi", + args: ["acp"], + cwd: scratch, + env: baseEnv, + fixtureName: "kimi.initialize.json", + }); + + report.configHomes = { + qwenHome, + kimiHome, + fakeHome, + qwenHomeListing: listRel(qwenHome), + kimiHomeListing: listRel(kimiHome), + fakeHomeListing: listRel(fakeHome), + kimiDoctor: capture("kimi", ["doctor"], baseEnv), + }; + + writeFileSync(path.join(here, "qwen-kimi-unauth-report.json"), `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + try { + rmSync(root, { recursive: true, force: true }); + } catch { + // tmp leftover is fine + } +} + +await main(); diff --git a/apps/desktop/src/main/services/chat/acpHost/index.ts b/apps/desktop/src/main/services/chat/acpHost/index.ts new file mode 100644 index 0000000000..5180470b39 --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/index.ts @@ -0,0 +1,107 @@ +/** + * The shared ACP host. + * + * One host module speaks the Agent Client Protocol. Four thin dialects describe + * what each provider does differently. Nothing outside this folder branches on + * a provider id. + * + * ## What W4 calls, and in what order + * + * 1. `acpDialectFor(providerId)` — get the descriptor. + * 2. `dialect.buildSpawnPlan({ binaryPath, cwd, baseEnv, ... })` — build the + * spawn plan. Pure. No process starts here. + * 3. `openAcpSession({ dialect, cwd, spawnPlan, sessionToken, existingSessionId, + * adeHasTranscript, mcpServers, callbacks, logger })` — acquires a pooled + * process, runs `initialize`, attaches handlers, and enters the session with + * `session/new`, `session/resume`, or `session/load`. It sends the + * post-session-new notifications a dialect asks for. + * 4. Persist `session.sessionId`. It is how the chat resumes. + * 5. `session.prompt({ turnId, blocks })` per turn. Publish + * `outcome.events` after the stream, and read `outcome.interrupted` rather + * than `outcome.stopReason` when deciding the turn status. + * 6. `session.cancel(reason)` to stop a turn. It answers every open permission + * request before it sends the cancel. + * 7. `session.close(reason)` when the chat ends. It uses `session/close` where + * the dialect has it, and it ends the private process where it does not. + * + * The host never writes a provider's config directory. Spawn plans carry argv + * and environment only; nothing under `$COPILOT_HOME`, `$QWEN_HOME`, + * `$KIMI_CODE_HOME`, or `~/.grok` is modified by ADE. + * + * The permission callbacks are not optional. `onPermissionRequested` receives a + * pending object; the host waits until something calls `select` or `cancel` on + * it. Nothing else answers the agent. + */ + +export * from "./acpProtocolTypes"; +export * from "./acpHostTypes"; +export { + ACP_DEFAULT_REQUEST_TIMEOUT_MS, + ACP_HANDSHAKE_TIMEOUT_MS, + ACP_TERMINATE_GRACE_MS, + AcpConnectionClosedError, + AcpRequestTimeoutError, + AcpRpcError, + createAcpConnection, + initializeAcpConnection, + type AcpConnection, + type AcpConnectionExit, +} from "./acpConnection"; +export { + ACP_IDLE_TTL_MS, + acpSessionPool, + buildAcpPoolKey, + createAcpSessionPool, + hashPoolEnv, + type AcpPooledConnection, + type AcpSessionPool, +} from "./acpSessionPool"; +export { + buildUnifiedDiff, + createAcpEventTranslator, + usageSampleToEvents, + type AcpEventTranslator, + type AcpToolRowKind, +} from "./acpEventTranslator"; +export { + createAcpPermissionBridge, + normalizePermissionOption, + pendingPermissionToInputRequest, + type AcpNormalizedPermissionOption, + type AcpPendingPermission, + type AcpPermissionBridge, +} from "./acpPermissionBridge"; +export { + acpSupervisionModeFor, + acpUnsupervisedNoticeDetail, + acpUnsupervisedNoticeMessage, + acpUnverifiedNoticeMessage, + createAcpSupervisionGuard, + type AcpSupervisionGuard, + type AcpSupervisionMode, +} from "./acpSupervisionGuard"; +export { + newAcpTurnId, + openAcpSession, + resolveAcpSessionEntry, + textPromptBlock, + type AcpSession, + type AcpSessionCallbacks, + type AcpSessionEntryPlan, + type AcpTurnOutcome, +} from "./acpSession"; +export { + buildAcpPromptBlocks, + type AcpResolvedAttachment, + type BuildAcpPromptBlocksArgs, +} from "./acpPromptBlocks"; +export { + acpHasTranscript, + acpInvocationKey, + createAcpRuntime, + type AcpRuntimeCoordinatorCallbacks, + type AcpRuntimeOwner, + type AcpRuntimeState, + type CreateAcpRuntimeArgs, +} from "./acpRuntimeCoordinator"; +export * from "./acpDialects"; diff --git a/apps/desktop/src/main/services/chat/acpHost/mockAcpAgent.ts b/apps/desktop/src/main/services/chat/acpHost/mockAcpAgent.ts new file mode 100644 index 0000000000..054ad188cc --- /dev/null +++ b/apps/desktop/src/main/services/chat/acpHost/mockAcpAgent.ts @@ -0,0 +1,250 @@ +/** + * A scripted in-process ACP agent, for tests. + * + * The mock is a fake `ChildProcessWithoutNullStreams`. It reads NDJSON frames + * from its stdin, and it writes NDJSON frames to its stdout. So the connection + * under test exercises its real framing, its real request correlation, and its + * real notification routing. Only the operating system process is replaced. + * + * This is the seed of the `run | degrade` conformance harness in the spec. A + * behavior is scripted with `on(method, handler)`. A method with no handler + * answers `-32601`, which is exactly what a real agent that lacks the method + * does. So a "gracefully absent" test needs no extra setup: leave the method + * unscripted and assert the host degrades instead of hanging. + */ + +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { + ACP_PROTOCOL_VERSION, + ACP_RPC_METHOD_NOT_FOUND, + type AcpAgentCapabilities, + type AcpAuthMethod, + type AcpSessionNotification, + type AcpSessionUpdate, +} from "./acpProtocolTypes"; + +export type MockAcpHandlerResult = + | { result: unknown } + | { error: { code: number; message: string; data?: unknown } }; + +export type MockAcpHandler = ( + params: unknown, + context: MockAcpAgent, +) => MockAcpHandlerResult | Promise; + +export type MockAcpAgentOptions = { + agentCapabilities?: AcpAgentCapabilities; + authMethods?: AcpAuthMethod[]; + /** Emit a banner line on stdout before the first frame, like some real CLIs. */ + bannerLine?: string; + /** Text to write to stderr at start. */ + stderrLine?: string; +}; + +export type MockAcpAgent = { + /** Pass this to `createAcpConnection({ spawnOverride })`. */ + child: ChildProcessWithoutNullStreams; + /** Register a request handler. Replaces any earlier one. */ + on(method: string, handler: MockAcpHandler): void; + /** Drop a handler, so the method answers -32601 again. */ + off(method: string): void; + /** Every request frame the agent received, in order. */ + readonly received: Array<{ method: string; params: unknown; isNotification: boolean }>; + /** Methods received, in order. Convenience for assertions. */ + methodsReceived(): string[]; + /** Send a `session/update` notification. */ + emitUpdate(sessionId: string, update: AcpSessionUpdate): void; + /** Send any notification. */ + emitNotification(method: string, params: unknown): void; + /** Send an agent-to-client request and resolve with the client's answer. */ + callClient(method: string, params: unknown): Promise; + /** Write raw text to stdout. Used for malformed-frame tests. */ + writeRaw(text: string): void; + /** End the process with a code. */ + exit(code: number): void; + /** Resolves after the agent has seen a request for `method`. */ + waitForMethod(method: string, timeoutMs?: number): Promise; +}; + +class MockChildProcess extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + pid: number | null = 4242; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + killed = false; + + kill(signal?: NodeJS.Signals | number): boolean { + if (this.exitCode !== null) return false; + this.killed = true; + this.signalCode = (typeof signal === "string" ? signal : "SIGTERM") as NodeJS.Signals; + queueMicrotask(() => { + if (this.exitCode !== null) return; + this.exitCode = null; + this.emit("exit", null, this.signalCode); + }); + return true; + } +} + +export function createMockAcpAgent(options: MockAcpAgentOptions = {}): MockAcpAgent { + const child = new MockChildProcess(); + const handlers = new Map(); + const received: Array<{ method: string; params: unknown; isNotification: boolean }> = []; + const methodWaiters = new Map void>>(); + const clientCalls = new Map void; reject: (error: Error) => void }>(); + let nextClientCallId = 1_000; + + const write = (frame: Record) => { + child.stdout.write(`${JSON.stringify(frame)}\n`); + }; + + const agent: MockAcpAgent = { + child: child as unknown as ChildProcessWithoutNullStreams, + on: (method, handler) => { + handlers.set(method, handler); + }, + off: (method) => { + handlers.delete(method); + }, + received, + methodsReceived: () => received.map((entry) => entry.method), + emitUpdate: (sessionId, update) => { + const params: AcpSessionNotification = { sessionId, update }; + write({ jsonrpc: "2.0", method: "session/update", params }); + }, + emitNotification: (method, params) => { + write({ jsonrpc: "2.0", method, params }); + }, + callClient: (method: string, params: unknown) => + new Promise((resolve, reject) => { + const id = nextClientCallId++; + clientCalls.set(id, { resolve: (value) => resolve(value as TResult), reject }); + write({ jsonrpc: "2.0", id, method, params }); + }), + writeRaw: (text) => { + child.stdout.write(text); + }, + exit: (code) => { + if (child.exitCode !== null) return; + child.exitCode = code; + child.emit("exit", code, null); + }, + waitForMethod: (method, timeoutMs = 2_000) => + new Promise((resolve, reject) => { + const already = received.find((entry) => entry.method === method); + if (already) { + resolve(already.params); + return; + } + const waiters = methodWaiters.get(method) ?? []; + waiters.push(resolve); + methodWaiters.set(method, waiters); + const timer = setTimeout(() => { + reject(new Error(`mock agent never received ${method} within ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref?.(); + }), + }; + + // Default handlers. `initialize` must always work, or nothing else can run. + handlers.set("initialize", () => ({ + result: { + protocolVersion: ACP_PROTOCOL_VERSION, + agentCapabilities: options.agentCapabilities ?? { + loadSession: true, + promptCapabilities: { image: true }, + mcpCapabilities: { http: true, sse: true }, + sessionCapabilities: { resume: {}, close: {}, list: {} }, + }, + ...(options.authMethods ? { authMethods: options.authMethods } : {}), + agentInfo: { name: "mock-acp-agent", version: "0.0.0" }, + }, + })); + + let buffer = ""; + child.stdin.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + let index = buffer.indexOf("\n"); + while (index !== -1) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + index = buffer.indexOf("\n"); + if (!line.length) continue; + let frame: Record; + try { + frame = JSON.parse(line) as Record; + } catch { + continue; + } + + const id = frame.id as number | string | undefined; + const method = typeof frame.method === "string" ? frame.method : null; + + if (!method && id !== undefined) { + // A response to an agent-to-client request. + const waiter = clientCalls.get(id as number); + if (!waiter) continue; + clientCalls.delete(id as number); + const error = frame.error as { message?: string } | undefined; + if (error) waiter.reject(new Error(error.message ?? "client error")); + else waiter.resolve(frame.result); + continue; + } + if (!method) continue; + + received.push({ method, params: frame.params, isNotification: id === undefined }); + const waiters = methodWaiters.get(method); + if (waiters?.length) { + methodWaiters.delete(method); + for (const waiter of waiters) waiter(frame.params); + } + + if (id === undefined) continue; // Notification. No answer. + + const handler = handlers.get(method); + if (!handler) { + write({ + jsonrpc: "2.0", + id, + error: { code: ACP_RPC_METHOD_NOT_FOUND, message: `mock agent has no ${method}` }, + }); + continue; + } + void (async () => { + try { + const outcome = await handler(frame.params, agent); + if ("error" in outcome) write({ jsonrpc: "2.0", id, error: outcome.error }); + else write({ jsonrpc: "2.0", id, result: outcome.result }); + } catch (error) { + write({ + jsonrpc: "2.0", + id, + error: { code: -32000, message: error instanceof Error ? error.message : String(error) }, + }); + } + })(); + } + }); + + if (options.bannerLine) child.stdout.write(`${options.bannerLine}\n`); + if (options.stderrLine) child.stderr.write(`${options.stderrLine}\n`); + + return agent; +} + +/** + * A `session/new` handler that answers with a fixed id. + * + * Most tests want this. Pass `configOptions` or `modes` when the test is about + * session configuration. + */ +export function respondWithSession( + sessionId: string, + extra: Record = {}, +): MockAcpHandler { + return () => ({ result: { sessionId, ...extra } }); +} diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 2db1394926..74ca6b0a90 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -14,6 +14,9 @@ import { startOpenCodeSession, } from "../opencode/openCodeRuntime"; import { cursorSdkSettingSources, evaluateCursorSdkHook, summarizeCursorHook } from "./cursorSdkPolicy"; +import { createMockAcpAgent, respondWithSession, type MockAcpAgent } from "./acpHost/mockAcpAgent"; +import { createAcpSessionPool } from "./acpHost/acpSessionPool"; +import type { AcpSessionUpdate } from "./acpHost/acpProtocolTypes"; import { openKvDb } from "../state/kvDb"; import { createCtoStateService } from "../cto/ctoStateService"; import { createCtoMemoryService } from "../cto/ctoMemoryService"; @@ -25,6 +28,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { injectFsFault } from "../../../test/faultInjection"; import { resolveBuiltInBrowserActorCapability } from "../builtInBrowser/builtInBrowserActorCapabilities"; +import { loadQwenUserSettings } from "../ai/qwenUserSettings"; const streamText = vi.fn(); const claudeSdkCreateSessionCompat = vi.hoisted(() => vi.fn()); @@ -735,6 +739,30 @@ vi.mock("./droidModelsDiscovery", () => ({ vi.mock("../ai/authDetector", () => ({ detectAllAuth: vi.fn(async () => []), + // The ACP adapter reads this to resolve a binary and to gate the catalog. + // Every ACP CLI reports installed and signed in unless a test says otherwise. + detectCliAuthStatuses: vi.fn(async () => + ["qwen", "kimi", "grok", "copilot"].map((cli) => ({ + cli, + installed: true, + path: `/usr/local/bin/${cli}`, + authenticated: true, + verified: false, + })), + ), +})); + +vi.mock("../ai/qwenUserSettings", () => ({ + loadQwenUserSettings: vi.fn(async () => ({ + authenticated: false, + models: [], + defaultModelId: null, + })), + parseQwenUserSettings: vi.fn(() => ({ + authenticated: false, + models: [], + defaultModelId: null, + })), })); vi.mock("../ai/localModelDiscovery", () => ({})); @@ -969,7 +997,7 @@ import { gunzipFromBase64, } from "./crossMachineForkTransport"; import { spawn } from "node:child_process"; -import { detectAllAuth } from "../ai/authDetector"; +import { detectAllAuth, detectCliAuthStatuses } from "../ai/authDetector"; import { buildCodingAgentSystemPrompt } from "../ai/tools/systemPrompt"; import { createOrchestrationService } from "../orchestration/orchestrationService"; import { runGit } from "../git/git"; @@ -27933,6 +27961,58 @@ describe("createAgentChatService", () => { expect(Array.isArray(models)).toBe(true); }); + it("uses the Qwen CLI's configured model instead of unrelated curated rows", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { + type: "cli-subscription", + cli: "qwen", + path: "/usr/local/bin/qwen", + authenticated: true, + verified: false, + }, + ] as never); + vi.mocked(detectCliAuthStatuses).mockResolvedValue([ + { + cli: "qwen", + installed: true, + path: "/usr/local/bin/qwen", + authenticated: true, + verified: false, + }, + ] as never); + vi.mocked(loadQwenUserSettings).mockResolvedValue({ + authenticated: true, + models: [{ id: "gpt-5.5", displayName: "gpt-5.5" }], + defaultModelId: "gpt-5.5", + }); + + const { service } = createService(); + const models = await service.getAvailableModels({ provider: "qwen", activateRuntime: true }); + + expect(models.map((model) => model.id)).toEqual(["qwen/gpt-5.5"]); + }); + + // Settings can switch a provider off. That has to mean the same thing on + // every surface, so the gate lives on the one call every picker, the + // catalog, and the cross-machine action all funnel through. + it("offers nothing for a provider the user disabled, directly or in aggregate", async () => { + const { service } = createService({ + projectConfigService: { + get: vi.fn(() => ({ effective: { ai: { disabledProviders: ["claude"] } } })), + getAll: vi.fn(() => ({})), + set: vi.fn(), + } as any, + }); + + expect(await service.getAvailableModels({ provider: "claude" })).toEqual([]); + + const aggregate = await service.getAvailableModels({}); + expect(aggregate.some((model) => model.family === "anthropic")).toBe(false); + + const catalog = await service.getModelCatalog({ mode: "force" }); + expect(catalog.groups.map((group) => group.key)).not.toContain("claude"); + }); + it("returns Cursor CLI models without requiring a Cursor SDK API key", async () => { delete process.env.CURSOR_API_KEY; vi.mocked(detectAllAuth).mockResolvedValue([ @@ -46706,3 +46786,565 @@ describe("claude output style listing", () => { expect((await service.getSessionSummary(session.id))?.claudeOutputStyle ?? null).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// ACP providers (qwen, kimi, grok, copilot) +// --------------------------------------------------------------------------- +// +// These drive the real ACP host: the scripted agent is a fake child process, so +// the framing, the request correlation and the permission round-trip under test +// are the production ones. Only the operating system process is replaced. + +describe("acp chat runtime", () => { + type AcpHarness = Awaited>; + + const acpTeardown: Array<() => void> = []; + + afterEach(() => { + for (const dispose of acpTeardown.splice(0)) dispose(); + }); + + async function openAcpHarness(options: { + provider: "qwen" | "kimi" | "grok" | "copilot"; + modelId: string; + model: string; + /** Extra `session/new` result fields, for config-option tests. */ + sessionExtra?: Record; + /** Seeded persisted state, for the resume test. */ + seedPersistedState?: Record; + sessionOverrides?: Record; + }) { + const agent = createMockAcpAgent(); + agent.on("session/new", respondWithSession("acp-session-1", options.sessionExtra ?? {})); + agent.on("session/load", respondWithSession("acp-session-1", options.sessionExtra ?? {})); + agent.on("session/resume", respondWithSession("acp-session-1", options.sessionExtra ?? {})); + agent.on("session/close", () => ({ result: {} })); + agent.on("session/set_config_option", () => ({ result: {} })); + agent.on("session/cancel", () => ({ result: {} })); + + const pool = createAcpSessionPool(); + acpTeardown.push(() => pool.disposeAll("test teardown")); + + const events: AgentChatEventEnvelope[] = []; + const harness = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + acpSpawnOverride: () => agent.child, + acpSessionPool: pool, + }); + const session = await harness.service.createSession({ + laneId: "lane-1", + provider: options.provider, + model: options.model, + modelId: options.modelId, + ...(options.sessionOverrides ?? {}), + }); + if (options.seedPersistedState) { + writePersistedChatState(session.id, { + ...readPersistedChatState(session.id), + ...options.seedPersistedState, + }); + } + acpTeardown.push(() => { void harness.service.disposeAll(); }); + return { agent, events, session, ...harness }; + } + + /** Types of the events emitted for one turn, in order. */ + function eventTypes(harness: Pick): string[] { + return harness.events.map((envelope) => envelope.event.type); + } + + function eventsOfType( + harness: Pick, + type: T, + ): Array> { + return harness.events + .map((envelope) => envelope.event as Record) + .filter((event) => event.type === type); + } + + /** Script one prompt turn: stream `updates`, then answer with `result`. */ + function scriptPrompt( + agent: MockAcpAgent, + updates: AcpSessionUpdate[], + result: Record = { stopReason: "end_turn" }, + ): void { + agent.on("session/prompt", async (params) => { + const sessionId = (params as { sessionId: string }).sessionId; + for (const update of updates) agent.emitUpdate(sessionId, update); + return { result }; + }); + } + + it("streams a turn as text then a terminal done, flushing text before the tool row", async () => { + // The flush invariant: buffered assistant text must be committed before any + // non-text event, or the tool row lands above the sentence that introduced + // it and the transcript reads backwards. + const harness = await openAcpHarness({ + provider: "qwen", + model: "qwen3-coder-plus", + modelId: "qwen/qwen3-coder-plus", + }); + scriptPrompt(harness.agent, [ + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Reading the file." } }, + { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Read src/index.ts", + kind: "read", + status: "completed", + rawInput: { path: "src/index.ts" }, + }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: " Done." } }, + ]); + + await harness.service.sendMessage({ sessionId: harness.session.id, text: "look at this" }); + await vi.waitFor(() => { + expect(eventTypes(harness)).toContain("done"); + }); + + const types = eventTypes(harness); + const firstText = types.indexOf("text"); + const toolCall = types.indexOf("tool_call"); + expect(firstText).toBeGreaterThanOrEqual(0); + expect(toolCall).toBeGreaterThan(firstText); + expect(types.indexOf("done")).toBeGreaterThan(toolCall); + + const done = eventsOfType(harness, "done").at(-1); + expect(done?.status).toBe("completed"); + const statuses = eventsOfType(harness, "status").map((event) => event.turnStatus); + expect(statuses).toContain("started"); + expect(statuses).toContain("completed"); + }); + + it("forwards image URL attachments in the ACP prompt payload", async () => { + const harness = await openAcpHarness({ + provider: "qwen", + model: "qwen3-coder-plus", + modelId: "qwen/qwen3-coder-plus", + }); + let promptParams: Record | null = null; + harness.agent.on("session/prompt", async (params) => { + promptParams = params as Record; + return { result: { stopReason: "end_turn" } }; + }); + + void harness.service.sendMessage({ + sessionId: harness.session.id, + text: "Review this image.", + attachments: [{ + path: "https://example.test/review.webp", + type: "image-url", + url: "https://example.test/review.webp", + }], + }); + + await vi.waitFor(() => { + expect(promptParams).not.toBeNull(); + }); + await vi.waitFor(() => { + expect(eventTypes(harness)).toContain("done"); + }); + + const sentPrompt = promptParams as unknown as Record; + expect(sentPrompt.prompt).toEqual([ + { type: "text", text: expect.stringContaining("Review this image.") }, + { + type: "image", + data: "", + mimeType: "image/webp", + uri: "https://example.test/review.webp", + }, + ]); + }); + + it("raises a permission request as a card and forwards the chosen option", async () => { + const harness = await openAcpHarness({ + provider: "qwen", + model: "qwen3-coder-plus", + modelId: "qwen/qwen3-coder-plus", + }); + let permissionAnswer: any = null; + harness.agent.on("session/prompt", async (params) => { + const sessionId = (params as { sessionId: string }).sessionId; + permissionAnswer = await harness.agent.callClient("session/request_permission", { + sessionId, + toolCall: { toolCallId: "tool-1", title: "Write src/index.ts", kind: "edit" }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }); + return { result: { stopReason: "end_turn" } }; + }); + + void harness.service.sendMessage({ sessionId: harness.session.id, text: "edit it" }); + await vi.waitFor(() => { + expect(eventsOfType(harness, "approval_request").length).toBe(1); + }); + const card = eventsOfType(harness, "approval_request")[0]!; + const request = card.detail?.request as { requestId: string; source: string }; + expect(request.source).toBe("acp"); + + await harness.service.respondToInput({ + sessionId: harness.session.id, + itemId: card.itemId, + decision: "accept", + }); + + await vi.waitFor(() => { + expect(permissionAnswer).not.toBeNull(); + }); + expect(permissionAnswer.outcome).toEqual({ outcome: "selected", optionId: "allow" }); + await vi.waitFor(() => { + expect(eventTypes(harness)).toContain("done"); + }); + }); + + it("sends the reject option when the user declines, never a silent allow", async () => { + const harness = await openAcpHarness({ + provider: "qwen", + model: "qwen3-coder-plus", + modelId: "qwen/qwen3-coder-plus", + }); + let permissionAnswer: any = null; + harness.agent.on("session/prompt", async (params) => { + const sessionId = (params as { sessionId: string }).sessionId; + permissionAnswer = await harness.agent.callClient("session/request_permission", { + sessionId, + toolCall: { toolCallId: "tool-1", title: "Run rm -rf", kind: "execute" }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }); + return { result: { stopReason: "end_turn" } }; + }); + + void harness.service.sendMessage({ sessionId: harness.session.id, text: "clean up" }); + await vi.waitFor(() => { + expect(eventsOfType(harness, "approval_request").length).toBe(1); + }); + await harness.service.respondToInput({ + sessionId: harness.session.id, + itemId: eventsOfType(harness, "approval_request")[0]!.itemId, + decision: "decline", + }); + + await vi.waitFor(() => { + expect(permissionAnswer).not.toBeNull(); + }); + expect(permissionAnswer.outcome).toEqual({ outcome: "selected", optionId: "reject" }); + }); + + it("answers an open permission request when the turn is interrupted", async () => { + // A card that outlives its turn blocks the agent behind something the user + // can no longer see. The interrupt has to settle it. + const harness = await openAcpHarness({ + provider: "qwen", + model: "qwen3-coder-plus", + modelId: "qwen/qwen3-coder-plus", + }); + let permissionAnswer: any = null; + harness.agent.on("session/prompt", async (params) => { + const sessionId = (params as { sessionId: string }).sessionId; + permissionAnswer = await harness.agent.callClient("session/request_permission", { + sessionId, + toolCall: { toolCallId: "tool-1", title: "Write a file", kind: "edit" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }); + return { result: { stopReason: "cancelled" } }; + }); + + void harness.service.sendMessage({ sessionId: harness.session.id, text: "edit it" }); + await vi.waitFor(() => { + expect(eventsOfType(harness, "approval_request").length).toBe(1); + }); + + await harness.service.interrupt({ sessionId: harness.session.id }); + + await vi.waitFor(() => { + expect(permissionAnswer).not.toBeNull(); + }); + expect(permissionAnswer.outcome).toEqual({ outcome: "cancelled" }); + await vi.waitFor(() => { + expect(eventsOfType(harness, "done").length).toBe(1); + }); + // The composer is released only by a terminal marker. An interrupted turn + // must still reach one. + expect(eventsOfType(harness, "done").at(-1)?.status).toBe("interrupted"); + }); + + it("reports a cancelled turn as interrupted even when the agent says end_turn", async () => { + // Copilot's known bug (github/copilot-cli #4561). ADE's own cancel record + // is the deciding source, never the agent's stopReason. + const harness = await openAcpHarness({ + provider: "copilot", + model: "claude-sonnet-4.6", + modelId: "github-copilot/claude-sonnet-4.6", + }); + let releasePrompt: (() => void) | null = null; + harness.agent.on("session/prompt", async () => { + await new Promise((resolve) => { releasePrompt = resolve; }); + return { result: { stopReason: "end_turn" } }; + }); + + void harness.service.sendMessage({ sessionId: harness.session.id, text: "work" }); + await vi.waitFor(() => { + expect(releasePrompt).not.toBeNull(); + }); + await harness.service.interrupt({ sessionId: harness.session.id }); + releasePrompt!(); + + await vi.waitFor(() => { + expect(eventsOfType(harness, "done").length).toBe(1); + }); + expect(eventsOfType(harness, "done").at(-1)?.status).toBe("interrupted"); + }); + + it("reopens an ACP runtime when permission mode changes during a turn", async () => { + const harness = await openAcpHarness({ + provider: "copilot", + model: "claude-sonnet-4.6", + modelId: "github-copilot/claude-sonnet-4.6", + sessionOverrides: { permissionMode: "full-auto" }, + }); + let releaseFirstPrompt: (() => void) | null = null; + let promptCount = 0; + let permissionAnswer: Record | null = null; + harness.agent.on("session/prompt", async (params) => { + promptCount += 1; + const sessionId = (params as { sessionId: string }).sessionId; + if (promptCount === 1) { + await new Promise((resolve) => { releaseFirstPrompt = resolve; }); + return { result: { stopReason: "end_turn" } }; + } + permissionAnswer = await harness.agent.callClient("session/request_permission", { + sessionId, + toolCall: { toolCallId: "tool-1", title: "Write src/index.ts", kind: "edit" }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }); + return { result: { stopReason: "end_turn" } }; + }); + + void harness.service.sendMessage({ sessionId: harness.session.id, text: "first" }); + await harness.agent.waitForMethod("session/prompt"); + expect(harness.session.acpPermissionMode).toBe("yolo"); + + await harness.service.updateSession({ sessionId: harness.session.id, permissionMode: "plan" }); + expect(harness.session.acpPermissionMode).toBe("plan"); + releaseFirstPrompt!(); + await vi.waitFor(() => { + expect(eventsOfType(harness, "done")).toHaveLength(1); + }); + + void harness.service.sendMessage({ sessionId: harness.session.id, text: "second" }); + await vi.waitFor(() => { + expect(eventsOfType(harness, "approval_request")).toHaveLength(1); + }); + // The old full-auto runtime would auto-approve this request. A second + // session entry proves the mode change rebuilt the provider boundary first; + // the resumed entry may be `session/resume` rather than `session/new`. + const sessionEntries = harness.agent.methodsReceived().filter((method) => + method === "session/new" || method === "session/load" || method === "session/resume", + ); + expect(sessionEntries).toHaveLength(2); + + await harness.service.respondToInput({ + sessionId: harness.session.id, + itemId: eventsOfType(harness, "approval_request")[0]!.itemId, + decision: "accept", + }); + await vi.waitFor(() => { + expect(permissionAnswer).not.toBeNull(); + expect(eventsOfType(harness, "done")).toHaveLength(2); + }); + expect(permissionAnswer).toMatchObject({ + outcome: { outcome: "selected", optionId: "allow" }, + }); + }); + + it("folds Grok's prompt-result usage into the turn", async () => { + const harness = await openAcpHarness({ + provider: "grok", + model: "grok-4.6", + modelId: "xai/grok-4-6", + }); + scriptPrompt(harness.agent, [], { + stopReason: "end_turn", + _meta: { + costUsdTicks: 2_500_000_000, + cachedReadTokens: 40, + modelUsage: { "grok-4.6": { inputTokens: 100, outputTokens: 20 } }, + }, + }); + + await harness.service.sendMessage({ sessionId: harness.session.id, text: "hi" }); + await vi.waitFor(() => { + expect(eventTypes(harness)).toContain("done"); + }); + + const tokens = eventsOfType(harness, "tokens").at(-1); + expect(tokens?.inputTokens).toBe(100); + expect(tokens?.outputTokens).toBe(20); + expect(tokens?.cacheReadTokens).toBe(40); + }); + + it("emits no usage for Kimi and says why once", async () => { + // Kimi 0.31.x reports nothing on the wire. Fabricating a zero would be a + // lie; saying nothing at all reads as a broken meter. So it says so. + const harness = await openAcpHarness({ + provider: "kimi", + model: "kimi-code/k3", + modelId: "moonshot/k3", + }); + scriptPrompt(harness.agent, [ + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } }, + ]); + + await harness.service.sendMessage({ sessionId: harness.session.id, text: "hi" }); + await vi.waitFor(() => { + expect(eventTypes(harness)).toContain("done"); + }); + + expect(eventsOfType(harness, "tokens")).toHaveLength(0); + const notices = eventsOfType(harness, "system_notice").map((event) => String(event.message)); + expect(notices.filter((message) => message.includes("token usage"))).toHaveLength(1); + + // Once per chat, not once per turn. + harness.events.length = 0; + await harness.service.sendMessage({ sessionId: harness.session.id, text: "again" }); + await vi.waitFor(() => { + expect(eventTypes(harness)).toContain("done"); + }); + expect( + eventsOfType(harness, "system_notice") + .map((event) => String(event.message)) + .filter((message) => message.includes("token usage")), + ).toHaveLength(0); + }); + + it("persists the agent's session id and rejoins with it after a restart", async () => { + const harness = await openAcpHarness({ + provider: "qwen", + model: "qwen3-coder-plus", + modelId: "qwen/qwen3-coder-plus", + }); + scriptPrompt(harness.agent, []); + await harness.service.sendMessage({ sessionId: harness.session.id, text: "hi" }); + await vi.waitFor(() => { + expect(eventTypes(harness)).toContain("done"); + }); + expect(readPersistedChatState(harness.session.id).acpSessionId).toBe("acp-session-1"); + + // A second service over the same persisted state is the restart: it must + // rejoin by id rather than start a fresh agent session. + const agent = createMockAcpAgent(); + const resumeCalls: unknown[] = []; + agent.on("session/resume", (params) => { + resumeCalls.push(params); + return { result: { sessionId: "acp-session-1" } }; + }); + agent.on("session/prompt", async () => ({ result: { stopReason: "end_turn" } })); + const pool = createAcpSessionPool(); + acpTeardown.push(() => pool.disposeAll("test teardown")); + // Same lane and session services: a restart re-reads ADE's own state, and + // a fresh mock registry would look like a session ADE had never heard of + // and send the reconciler down the continuity-recovery path. + const restarted = createService({ + acpSpawnOverride: () => agent.child, + acpSessionPool: pool, + laneService: harness.laneService, + sessionService: harness.sessionService, + }); + acpTeardown.push(() => { void restarted.service.disposeAll(); }); + + await restarted.service.sendMessage({ sessionId: harness.session.id, text: "still here?" }); + await vi.waitFor(() => { + expect(resumeCalls.length).toBe(1); + }); + expect(resumeCalls[0]).toMatchObject({ sessionId: "acp-session-1" }); + expect(agent.methodsReceived()).not.toContain("session/new"); + }); + + it("offers the agent's advertised slash commands, deduped and TUI-filtered", async () => { + const harness = await openAcpHarness({ + provider: "copilot", + model: "claude-sonnet-4.6", + modelId: "github-copilot/claude-sonnet-4.6", + }); + harness.agent.on("session/prompt", async (params) => { + const sessionId = (params as { sessionId: string }).sessionId; + const availableCommands = [ + { name: "review", description: "Review the diff" }, + // Copilot's terminal-only commands would reach the model as prose. + { name: "diff", description: "Show the diff" }, + { name: "login", description: "Sign in" }, + ]; + agentEmitCommands(harness.agent, sessionId, availableCommands); + // Re-sent on the same turn: the picker must not show it twice. + agentEmitCommands(harness.agent, sessionId, availableCommands); + return { result: { stopReason: "end_turn" } }; + }); + + await harness.service.sendMessage({ sessionId: harness.session.id, text: "hi" }); + await vi.waitFor(() => { + expect(eventTypes(harness)).toContain("done"); + }); + + const commands = harness.service.getSlashCommands({ sessionId: harness.session.id }); + const names = commands.map((command) => command.name); + expect(names.filter((name) => name === "/review")).toHaveLength(1); + expect(names).not.toContain("/diff"); + expect(names).not.toContain("/login"); + }); + + it("emits one visible error and a terminal done when the agent cannot start", async () => { + // A chat that never reaches `done` leaves the composer locked with nothing + // on screen explaining why. + const agent = createMockAcpAgent(); + agent.on("session/new", () => ({ + error: { code: -32000, message: "Authentication required: Use Qwen Code CLI to authenticate first." }, + })); + const pool = createAcpSessionPool(); + acpTeardown.push(() => pool.disposeAll("test teardown")); + const events: AgentChatEventEnvelope[] = []; + const harness = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + acpSpawnOverride: () => agent.child, + acpSessionPool: pool, + }); + acpTeardown.push(() => { void harness.service.disposeAll(); }); + const session = await harness.service.createSession({ + laneId: "lane-1", + provider: "qwen", + model: "qwen3-coder-plus", + modelId: "qwen/qwen3-coder-plus", + }); + + await harness.service.sendMessage({ sessionId: session.id, text: "hi" }); + await vi.waitFor(() => { + expect(events.some((envelope) => envelope.event.type === "done")).toBe(true); + }); + + const errors = events.map((e) => e.event as Record).filter((e) => e.type === "error"); + expect(errors).toHaveLength(1); + expect(String(errors[0]?.message)).toContain("Authentication required"); + expect(errors[0]?.errorInfo?.category).toBe("agent_cli_auth"); + expect(errors[0]?.errorInfo?.agentCli?.agent).toBe("qwen"); + expect(errors[0]?.errorInfo?.agentCli?.authCommand).toBe("qwen --auth-type=openai"); + const done = events.map((e) => e.event as Record).filter((e) => e.type === "done").at(-1); + expect(done?.status).toBe("failed"); + }); +}); + +/** Emit an `available_commands_update` for a scripted agent. */ +function agentEmitCommands( + agent: MockAcpAgent, + sessionId: string, + availableCommands: Array<{ name: string; description: string }>, +): void { + agent.emitUpdate(sessionId, { sessionUpdate: "available_commands_update", availableCommands }); +} diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 25f99baf45..1f8f38f31d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -409,10 +409,14 @@ import { providerSupportsHandoffFork, } from "../../../shared/types"; import { + isAcpChatProvider, spawnCompletedNoticeMessage, supportsActiveTurnDispatchMode, unsupportedActiveTurnDispatchModeMessage, waitingOnYouDescription, + type AcpChatProvider, + type AgentChatAcpConfigSnapshot, + type AgentChatAcpPermissionMode, } from "../../../shared/types/chat"; import { providerDisplayLabel } from "../../../shared/pendingInputLabels"; import { @@ -469,7 +473,10 @@ import { getModelById, getAvailableModels as getRegistryModels, getLocalProviderDefaultEndpoint, + createDynamicAcpModelDescriptor, + listAcpModelDescriptorsForProvider, listModelDescriptorsForProvider, + mergeDynamicAcpModelDescriptors, LOCAL_PROVIDER_LABELS, MODEL_REGISTRY, pickDefaultCursorDescriptorFromCliList, @@ -485,11 +492,14 @@ import { type ModelProviderGroup, } from "../../../shared/modelRegistry"; import { piSdkToolPolicyForPermissionMode } from "../../../shared/cliLaunch"; +import { isProviderDisabled } from "../../../shared/providerEnablement"; import { buildProviderGroupBlocks, createModelOrderMap, } from "../../../shared/modelCatalog"; -import { detectAllAuth } from "../ai/authDetector"; +import { detectAllAuth, detectCliAuthStatuses } from "../ai/authDetector"; +import { probeAcpProviderAuth } from "../ai/acpAuthProbe"; +import { loadQwenUserSettings } from "../ai/qwenUserSettings"; import type { AskUserToolInput, AskUserToolResult, @@ -620,6 +630,29 @@ import { import { peekOpenCodeInventoryCache, probeOpenCodeProviderInventory } from "../opencode/openCodeInventory"; import { inspectLocalProvider } from "../ai/localModelDiscovery"; import { resolveDroidExecutable } from "../ai/droidExecutable"; +import { checkKimiWindowsPrerequisites, resolveAcpExecutable } from "../ai/acpExecutables"; +import { checkGrokPermissionNeutralization } from "../ai/grokPermissionPreflight"; +import { isAcpAuthError, recordAcpAuthProbeResult } from "../ai/acpAuthProbe"; +import { + acpDialectFor, + acpHasTranscript, + acpInvocationKey, + buildAcpPromptBlocks, + createAcpRuntime, + openAcpSession, + pendingPermissionToInputRequest, + textPromptBlock, + type AcpDialect, + type AcpPendingPermission, + type AcpSessionConfigOption, + type AcpSlashCommand, + type AcpRuntimeState, +} from "./acpHost"; +import { + copilotConfigHome, + kimiCodeConfigHome, + qwenConfigHome, +} from "../shared/providerConfigHomes"; import { acquireCursorSdkConnection, isCursorSdkPooledAlive, @@ -1123,6 +1156,23 @@ type PersistedChatState = { importedFrom?: AgentChatImportedFrom; /** Factory Droid SDK session id for Droid resume across app restarts (best-effort). */ droidSdkSessionId?: string; + /** + * Agent-minted ACP session id. It is the only handle a restarted ADE has on + * an existing ACP conversation, so it is persisted for all four providers. + */ + acpSessionId?: string; + /** Abstract permission posture the ACP session was opened with. */ + acpPermissionMode?: AgentChatAcpPermissionMode; + /** Last config-option snapshot the agent reported, for the settings page. */ + acpConfigSnapshot?: AgentChatAcpConfigSnapshot; + /** Degradation notes already shown for this chat, so each is emitted once. */ + acpDegradationNotesShown?: string[]; + /** + * True once ADE told this chat that its ACP agent approves its own writes. + * Persisted so the honest-degradation line stays once per chat rather than + * once per runtime start. + */ + acpSupervisionNoticeShown?: boolean; /** Pi-native JSONL session pointer for SDK resume and CLI handoff. */ piSessionId?: string; piSessionFile?: string; @@ -1265,6 +1315,15 @@ function normalizeContinuityRecovery(value: unknown): AgentChatContinuityRecover }; } +/** The abstract ACP permission ladder, in order. Mirrors `AgentChatAcpPermissionMode`. */ +const ACP_PERMISSION_MODES: readonly AgentChatAcpPermissionMode[] = [ + "plan", + "default", + "auto-edit", + "auto", + "yolo", +]; + function isPersistedChatStateShape(value: unknown): value is PersistedChatState { if (!value || typeof value !== "object") return false; const record = value as Partial; @@ -1331,8 +1390,13 @@ function normalizedPersistedPointer(value: unknown): string | null { } function persistedPointerState(state: Pick): { provider: ThreadPointerLedgerEntry["provider"]; pointer: string | null } { + // Every ACP provider stores its pointer in the same field, so they share one + // arm rather than four identical ones. + if (isAcpChatProvider(state.provider)) { + return { provider: state.provider, pointer: normalizedPersistedPointer(state.acpSessionId) }; + } switch (state.provider) { case "codex": return { provider: state.provider, pointer: normalizedPersistedPointer(state.threadId) }; @@ -2200,7 +2264,20 @@ type PiRuntime = { toolPolicyKey: string; }; -type ChatRuntime = CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime | PiRuntime; +/** + * One ACP chat, on one open ACP session. + * + * Deliberately thin. The `acpHost` module owns the protocol, the process pool, + * cancel accounting, and the permission round-trip; this record holds only what + * ADE needs to route a turn and to know when the runtime must be rebuilt. + * + * `invocationKey` is the reason a model or effort change restarts the process: + * Grok and Copilot take those as process-global spawn flags that `session/new` + * cannot override, so a chat that changed one may not keep its old process. + */ +type AcpRuntime = AcpRuntimeState; + +type ChatRuntime = CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime | PiRuntime | AcpRuntime; function cancelCursorPermissionWaiter(waiter: CursorPermissionWaiter, reason: string): void { waiter.resolve(denyCursorHook(reason)); @@ -2484,7 +2561,11 @@ function validateSessionReadyForTurn(managed: ManagedChatSession): { ready: true if (!managed.runtime) return { ready: false, reason: "No runtime initialized" }; if (hasLivePendingInput(managed)) return { ready: false, reason: PENDING_INPUT_SEND_BLOCKED_MESSAGE }; const rt = managed.runtime; - if ((rt.kind === "opencode" || rt.kind === "claude" || rt.kind === "cursor" || rt.kind === "droid" || rt.kind === "pi") && rt.busy) { + if ( + (rt.kind === "opencode" || rt.kind === "claude" || rt.kind === "cursor" || rt.kind === "droid" + || rt.kind === "pi" || rt.kind === "acp") + && rt.busy + ) { return { ready: false, reason: "Turn already active" }; } if (rt.kind === "opencode" && rt.pendingApprovals.size > 0) return { ready: false, reason: "Pending approvals not resolved" }; @@ -2503,7 +2584,9 @@ function hasLivePendingInput(managed: ManagedChatSession | null | undefined): bo if (runtime.kind === "claude") return runtime.approvals.size > 0; if (runtime.kind === "opencode") return runtime.pendingApprovals.size > 0; if (runtime.kind === "cursor" || runtime.kind === "droid") return runtime.permissionWaiters.size > 0; - if (runtime.kind === "pi") return false; + // Pi and the ACP providers both raise their cards through + // `localPendingInputs`, which the check above already covered. + if (runtime.kind === "pi" || runtime.kind === "acp") return false; return false; } @@ -2579,6 +2662,10 @@ function runtimeBackgroundWork(runtime: ChatRuntime | null): SessionBackgroundWo case "opencode": case "droid": case "pi": + // ACP: a session is bounded by its prompt. No dialect exposes a background + // task, a scheduled run, or anything else that outlives a turn, and none is + // in `SUBAGENT_CAPABILITIES`. Zero here is a verified fact, not a default. + case "acp": return NO_BACKGROUND_WORK; default: { // A new harness must state whether it owns work that outlives a turn. @@ -3288,6 +3375,18 @@ type ManagedChatSession = { seededDroidSdkSessionId?: string; seededPiSessionId?: string; seededPiSessionFile?: string; + seededAcpSessionId?: string; + /** + * Degradation notes already emitted for this chat. Mirrors the persisted set + * so a note stays once-per-session across a runtime restart, and so W6 does + * not have to reach into the runtime to know what was already said. + */ + acpDegradationNotesShown?: Set; + /** + * True once the ACP supervision notice fired for this chat. Mirrors the + * persisted flag so the line stays once per chat across a runtime restart. + */ + acpSupervisionNoticeShown?: boolean; }; type HandoffArtifacts = { @@ -3723,6 +3822,32 @@ function codexModelInfoFromDescriptor( }; } +function acpModelInfoFromDescriptor( + descriptor: ModelDescriptor, + provider: AcpChatProvider, + index: number, +): AgentChatModelInfo { + return { + id: descriptor.id, + displayName: descriptor.displayName, + description: `${descriptor.displayName} (${providerDisplayLabel(provider, provider)})`, + isDefault: index === 0, + reasoningEfforts: descriptor.reasoningTiers?.map((effort) => ({ + effort, + description: `${effort} reasoning`, + })) ?? [], + modelId: descriptor.id, + family: descriptor.family, + supportsReasoning: descriptor.capabilities.reasoning, + supportsTools: descriptor.capabilities.tools, + ...(descriptor.defaultReasoningEffort + ? { defaultReasoningEffort: descriptor.defaultReasoningEffort } + : {}), + color: descriptor.color, + ...(descriptor.aliases?.length ? { aliases: descriptor.aliases } : {}), + }; +} + const CODEX_FALLBACK_MODELS: AgentChatModelInfo[] = listModelDescriptorsForProvider("codex").map((descriptor) => codexModelInfoFromDescriptor(descriptor) ); @@ -7079,6 +7204,7 @@ function enforceOrchestrationLockedPermissionMode( | "codexConfigSource" | "opencodePermissionMode" | "droidPermissionMode" + | "acpPermissionMode" | "cursorModeId" >, ): boolean { @@ -7998,6 +8124,17 @@ export function createAgentChatService(args: { * tests. */ runtimeBudget?: ChatRuntimeBudget; + /** + * Test seam. Replaces the real ACP agent spawn with a scripted process, so + * the conformance tests exercise the actual framing, request correlation and + * permission round-trip rather than a mock of the host. + */ + acpSpawnOverride?: Parameters[0]["spawnOverride"]; + /** + * Test seam. A private ACP connection pool, so one test's scripted agent is + * never handed to the next test that happens to share a lane path. + */ + acpSessionPool?: Parameters[0]["pool"]; }) { const runtimeBudget = args.runtimeBudget ?? createChatRuntimeBudget(); const { @@ -13603,6 +13740,30 @@ export function createAgentChatService(args: { : !managed.runtimeInvalidated && (managed.seededDroidSdkSessionId || prevPersisted?.droidSdkSessionId) ? { droidSdkSessionId: managed.seededDroidSdkSessionId ?? prevPersisted?.droidSdkSessionId } : {}), + // ACP pointer. Same three-tier shape as Droid: the live runtime's id + // first, then the fork seed or the previous write, and nothing at all + // once the runtime has been invalidated. + ...(managed.runtime?.kind === "acp" && managed.runtime.session.sessionId + ? { acpSessionId: managed.runtime.session.sessionId } + : !managed.runtimeInvalidated && (managed.seededAcpSessionId || prevPersisted?.acpSessionId) + ? { acpSessionId: managed.seededAcpSessionId ?? prevPersisted?.acpSessionId } + : {}), + ...(managed.session.acpPermissionMode + ? { acpPermissionMode: managed.session.acpPermissionMode } + : prevPersisted?.acpPermissionMode ? { acpPermissionMode: prevPersisted.acpPermissionMode } : {}), + ...(managed.session.acpConfigSnapshot + ? { acpConfigSnapshot: managed.session.acpConfigSnapshot } + : prevPersisted?.acpConfigSnapshot ? { acpConfigSnapshot: prevPersisted.acpConfigSnapshot } : {}), + ...(managed.acpDegradationNotesShown?.size + ? { acpDegradationNotesShown: [...managed.acpDegradationNotesShown] } + : prevPersisted?.acpDegradationNotesShown?.length + ? { acpDegradationNotesShown: prevPersisted.acpDegradationNotesShown } + : {}), + // Latching: once said, always remembered. A live runtime that has not yet + // tripped the invariant must not erase a flag an earlier run set. + ...(managed.acpSupervisionNoticeShown || prevPersisted?.acpSupervisionNoticeShown + ? { acpSupervisionNoticeShown: true } + : {}), ...(managed.runtime?.kind === "pi" && !managed.runtimeInvalidated ? { ...(managed.runtime.sdk.sessionId ? { piSessionId: managed.runtime.sdk.sessionId } : {}), @@ -13830,7 +13991,16 @@ export function createAgentChatService(args: { const record = recovered.value as Partial; let provider = record.provider; if (provider === "unified") provider = "opencode"; - if (provider !== "codex" && provider !== "claude" && provider !== "opencode" && provider !== "cursor" && provider !== "droid" && provider !== "pi") { + if ( + provider !== "codex" && provider !== "claude" && provider !== "opencode" + && provider !== "cursor" && provider !== "droid" && provider !== "pi" + && !isAcpChatProvider(provider) + ) { + // A provider this build does not know is not a state ADE can hydrate. + // Returning null sends the caller to the pointer reconciler, which is + // right for corrupt state and wrong for a provider that simply was not + // on this list — which is how an ACP chat used to come back from a + // restart demanding continuity recovery it did not need. return null; } const laneId = String(record.laneId ?? "").trim(); @@ -13898,6 +14068,22 @@ export function createAgentChatService(args: { const codexTokenUsage = codexTokenUsageRecord ? normalizeCodexThreadTokenUsage(codexTokenUsageRecord) : null; const importedFrom = normalizeImportedFrom(record.importedFrom); const continuityRecovery = normalizeContinuityRecovery(record.continuityRecovery); + // ACP: one pointer field, one abstract posture, one discovered-config + // snapshot, shared by all four providers. + const acpSessionId = typeof record.acpSessionId === "string" && record.acpSessionId.trim().length + ? record.acpSessionId.trim() + : null; + const acpPermissionMode = ACP_PERMISSION_MODES.includes(record.acpPermissionMode as AgentChatAcpPermissionMode) + ? record.acpPermissionMode as AgentChatAcpPermissionMode + : null; + const acpConfigSnapshotRecord = asRecord(record.acpConfigSnapshot); + const acpConfigSnapshot = acpConfigSnapshotRecord + ? acpConfigSnapshotRecord as AgentChatAcpConfigSnapshot + : null; + const acpDegradationNotesShown = Array.isArray(record.acpDegradationNotesShown) + ? record.acpDegradationNotesShown.filter((note): note is string => typeof note === "string" && note.length > 0) + : []; + const acpSupervisionNoticeShown = record.acpSupervisionNoticeShown === true; if (!laneId || !model) return null; const recentConversationEntries = Array.isArray(record.recentConversationEntries) ? record.recentConversationEntries @@ -14066,6 +14252,11 @@ export function createAgentChatService(args: { ...(sdkSessionId ? { sdkSessionId } : {}), ...(forkFromSdkSessionId ? { forkFromSdkSessionId } : {}), ...(providerSessionId ? { providerSessionId } : {}), + ...(acpSessionId ? { acpSessionId } : {}), + ...(acpPermissionMode ? { acpPermissionMode } : {}), + ...(acpConfigSnapshot ? { acpConfigSnapshot } : {}), + ...(acpDegradationNotesShown.length ? { acpDegradationNotesShown } : {}), + ...(acpSupervisionNoticeShown ? { acpSupervisionNoticeShown } : {}), ...(piSessionId ? { piSessionId } : {}), ...(piSessionFile ? { piSessionFile } : {}), ...(piProfileId ? { piProfileId } : {}), @@ -14196,6 +14387,10 @@ export function createAgentChatService(args: { case "pi": return { piSessionId: candidate.pointer }; case "cursor": return { cursorSdkAgentId: candidate.pointer }; case "opencode": return { providerSessionId: candidate.pointer }; + case "qwen": + case "kimi": + case "grok": + case "copilot": return { acpSessionId: candidate.pointer }; default: return {}; } }; @@ -14665,6 +14860,41 @@ export function createAgentChatService(args: { setSessionPreview(managed, event.text); }; + /** Install command each ACP CLI documents. Only used to write the error card. */ + const ACP_INSTALL_COMMANDS: Record = { + qwen: "npm install -g @qwen-code/qwen-code", + kimi: "curl -LsSf https://code.kimi.com/kimi-code/install.sh | bash", + grok: "npm install -g @xai-official/grok", + copilot: "npm install -g @github/copilot", + }; + + /** + * Turn an ACP failure into the same card shape the tracked CLIs produce. + * + * Only two verdicts are reachable here: the binary is missing, or the + * credential is not good. Anything else is a real error and keeps the red + * frame, because telling someone to log in when the agent crashed sends them + * to fix the wrong thing. + */ + const acpAuthErrorMatch = ( + provider: AgentChatProvider, + text: string, + ): ReturnType => { + if (!isAcpChatProvider(provider)) return null; + const dialect = acpDialectFor(provider); + const base = { + agent: provider, + displayName: dialect.displayName, + installCommand: ACP_INSTALL_COMMANDS[provider], + authCommand: dialect.authProbe.loginCommand, + }; + if (/\b(command not found|not recognized|enoent|no such file or directory|was not found on this machine)\b/i.test(text)) { + return { ...base, category: "missing" as const }; + } + if (isAcpAuthError(text)) return { ...base, category: "unauthenticated" as const }; + return null; + }; + const decorateAgentCliError = ( managed: ManagedChatSession, event: Extract, @@ -14672,7 +14902,13 @@ export function createAgentChatService(args: { const existingInfo = typeof event.errorInfo === "object" && event.errorInfo ? event.errorInfo : null; if (existingInfo?.agentCli) return event; - const match = classifyAgentCliError(`${event.message}\n${event.detail ?? ""}`, managed.session.provider); + const text = `${event.message}\n${event.detail ?? ""}`; + const match = classifyAgentCliError(text, managed.session.provider) + // The agent-CLI registry that classifier reads is the TUI's, and it has + // no entries for the ACP providers. Without this the calm "sign in and + // retry" card never appears for them and a logged-out Qwen chat shows a + // red crash frame with a JSON-RPC string in it. + ?? acpAuthErrorMatch(managed.session.provider, text); if (!match) return event; if (managed.session.provider === "claude" && match.category === "missing") { const resolved = resolveClaudeCodeExecutable(); @@ -18096,7 +18332,11 @@ export function createAgentChatService(args: { } const preserveProviderResumeState = - (managed.runtime.kind === "claude" || managed.runtime.kind === "cursor" || managed.runtime.kind === "pi") && reasonAllowsPreservation; + (managed.runtime.kind === "claude" || managed.runtime.kind === "cursor" || managed.runtime.kind === "pi" + // Every ACP dialect can rejoin a session by id, so an idle or + // shutdown teardown must keep the pointer it would resume from. + || managed.runtime.kind === "acp") + && reasonAllowsPreservation; if (managed.runtime.kind === "codex") { const runtime = managed.runtime; failOpenCodexCompactions(managed, runtime, "teardown"); @@ -18258,6 +18498,25 @@ export function createAgentChatService(args: { releasePiSdkConnection(rt.poolKey, rt.poolGeneration, () => lease?.release()); managed.runtime = null; } + if (managed.runtime?.kind === "acp") { + const rt = managed.runtime; + rt.interrupted = true; + cancelQueuedSteers(managed, rt, "interrupted"); + cancelPendingInputsFrom(managed, "acp", "ade"); + // Persist before detaching: the ACP session id is this chat's only handle + // on the conversation, and a preserved teardown must not lose it. + if (preserveProviderResumeState) persistChatState(managed); + // `close` is idempotent. It sends `session/close` where the dialect has + // it, and ends the private process where it does not (Kimi). + void rt.session.close(openCodeReason).catch((error) => { + logger.warn("agent_chat.acp_close_failed", { + sessionId: managed.session.id, + provider: rt.provider, + error: error instanceof Error ? error.message : String(error), + }); + }); + managed.runtime = null; + } managed.runtimeInvalidated = !preserveProviderResumeState; if (!preserveProviderResumeState) { clearLaneDirectiveKey(managed); @@ -18753,12 +19012,12 @@ export function createAgentChatService(args: { } const runtime = managed.runtime; const attachments = args.attachments ?? []; - const contextAttachments = args.contextAttachments ?? []; const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ ...attachment, _resolvedPath: attachment.path, _rootPath: managed.laneWorktreePath, })); + const contextAttachments = args.contextAttachments ?? []; const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; const userText = args.userText?.trim().length ? args.userText.trim() : displayText; let onDispatched = args.onDispatched; @@ -24000,6 +24259,598 @@ export function createAgentChatService(args: { } }; + // ── ACP providers: qwen, kimi, grok, copilot ──────────────────────────────── + // + // One adapter for four providers. Everything that differs between them lives + // in `acpHost/acpDialects`, so nothing below branches on a provider id except + // the places where ADE itself must: the config home each CLI honors, Kimi's + // Windows prerequisite check, and Grok's supervision preflight. + + /** Config home to hand a dialect's spawn plan. Grok honors no override. */ + const acpConfigHomeFor = ( + provider: AcpChatProvider, + env: NodeJS.ProcessEnv, + ): string | null => { + switch (provider) { + case "qwen": return qwenConfigHome({ env }); + case "kimi": return kimiCodeConfigHome({ env }); + case "copilot": return copilotConfigHome({ env }); + // Grok reads `~/.grok` and nothing else, so ADE sets nothing. + case "grok": return null; + } + }; + + /** + * The abstract permission posture for an ACP chat. + * + * The session's own `acpPermissionMode` wins when the user set one. Otherwise + * ADE's generic composer mode is collapsed onto the ACP vocabulary, which is + * the same five-step ladder every dialect maps from. + */ + const acpPermissionModeFromLegacyPermissionMode = ( + mode: AgentChatSession["permissionMode"] | undefined, + ): AgentChatAcpPermissionMode => { + switch (mode) { + case "plan": return "plan"; + case "edit": return "auto-edit"; + case "auto": return "auto"; + case "full-auto": return "yolo"; + default: return "default"; + } + }; + + const resolveAcpPermissionMode = (session: AgentChatSession): AgentChatAcpPermissionMode => + session.acpPermissionMode ?? acpPermissionModeFromLegacyPermissionMode(session.permissionMode); + + /** + * True when ADE should answer a permission card itself rather than show it. + * + * Only the top of the ladder auto-approves, and only where the dialect could + * not be told to do it server-side. The answer is still emitted as a resolved + * pending-input row, so the transcript shows what was approved and by whom — + * the same contract as Pi's full-auto. + */ + const acpAutoApprovesPermissions = ( + dialect: AcpDialect, + mode: AgentChatAcpPermissionMode, + ): boolean => { + if (mode !== "yolo") return false; + // Qwen takes the whole posture through `session/set_config_option`, so the + // agent stops asking and there is nothing for ADE to auto-answer. + return !dialect.sessionConfig.declared; + }; + + /** Native `mode` value for a dialect that accepts one. Qwen's ladder. */ + const acpNativeModeValue = (mode: AgentChatAcpPermissionMode): string => mode; + + /** Provider-native model token for the spawn plan, when the user picked one. */ + const acpModelTokenFor = (session: AgentChatSession): string | null => { + const descriptor = resolveSessionModelDescriptor(session); + const token = descriptor?.providerModelId?.trim() || session.model?.trim() || ""; + return token.length ? token : null; + }; + + /** + * Emit each of a dialect's declared holes once per chat. + * + * The note is honest degradation, not an error: Kimi genuinely reports no + * usage, and a user who cannot see why the meter vanished assumes ADE broke. + * The shown-set is persisted so a runtime restart does not repeat it. + */ + const emitAcpDegradationNotes = (managed: ManagedChatSession, dialect: AcpDialect): void => { + if (!dialect.degradationNotes.length) return; + if (!managed.acpDegradationNotesShown) { + managed.acpDegradationNotesShown = new Set( + readPersistedState(managed.session.id)?.acpDegradationNotesShown ?? [], + ); + } + let emitted = false; + for (const note of dialect.degradationNotes) { + if (managed.acpDegradationNotesShown.has(note)) continue; + managed.acpDegradationNotesShown.add(note); + emitted = true; + emitChatEvent(managed, { type: "system_notice", noticeKind: "info", message: note }); + } + if (emitted) persistChatState(managed); + }; + + /** Fold a config-option report into the session's snapshot and publish it. */ + const applyAcpConfigOptions = ( + managed: ManagedChatSession, + runtime: AcpRuntime, + snapshot: { options: AcpSessionConfigOption[]; currentModeId: string | null }, + ): void => { + if (managed.runtime !== runtime) return; + // A `current_mode_update` carries only the mode, and a + // `config_option_update` carries only the options. Merging rather than + // replacing keeps whichever half this report did not mention. + if (snapshot.options.length) runtime.configOptions = snapshot.options; + if (snapshot.currentModeId) runtime.currentModeId = snapshot.currentModeId; + + const modeOption = runtime.configOptions.find((option) => option.id === "mode"); + const modelOption = runtime.configOptions.find((option) => option.id === "model"); + const optionValues = (option: AcpSessionConfigOption | undefined): string[] => + (option?.options ?? []) + .map((entry) => entry.id.trim()) + .filter((value) => value.length > 0); + const currentValueOf = ( + option: AcpSessionConfigOption | undefined, + ): NonNullable[number]["currentValue"] => { + const value = option?.value; + return typeof value === "string" || typeof value === "boolean" || typeof value === "number" + ? value + : null; + }; + + const next: AgentChatAcpConfigSnapshot = { + currentModeId: runtime.currentModeId, + availableModeIds: optionValues(modeOption), + currentModelId: typeof currentValueOf(modelOption) === "string" + ? currentValueOf(modelOption) as string + : null, + availableModelIds: optionValues(modelOption), + configOptions: runtime.configOptions.map((option) => ({ + id: option.id, + name: option.name, + description: option.description ?? null, + category: option.category ?? null, + type: option.type === "boolean" ? "boolean" as const : "select" as const, + currentValue: currentValueOf(option), + ...(option.options?.length + ? { + options: option.options.map((entry) => ({ + value: entry.id, + label: entry.name, + ...(entry.description != null ? { description: entry.description } : {}), + })), + } + : {}), + })), + }; + managed.session.acpConfigSnapshot = next; + persistChatState(managed); + emitChatEvent(managed, { type: "session_meta_updated", acpConfigSnapshot: next }); + + // Live model discovery. The agent named the models this account can reach, + // so they join the registry alongside the curated rows. + if (next.availableModelIds?.length) { + mergeDynamicAcpModelDescriptors( + runtime.provider, + next.availableModelIds.map((modelId) => createDynamicAcpModelDescriptor(runtime.provider, modelId)), + ); + } + }; + + /** + * Raise one ACP permission request as an ADE pending-input card. + * + * The host is blocked on `pending.select` / `pending.cancel` until something + * answers, so every exit from here must end in one of the two. Teardown and + * interrupt drain `localPendingInputs`, which is why the card lives there + * rather than on the runtime: a card must survive the runtime it came from + * long enough to be answered. + */ + const presentAcpPermissionRequest = ( + managed: ManagedChatSession, + runtime: AcpRuntime, + pending: AcpPendingPermission, + ): void => { + if (managed.runtime !== runtime) { + pending.cancel(); + return; + } + const request = pendingPermissionToInputRequest({ + pending, + source: "acp", + providerLabel: runtime.dialect.displayName, + }); + // `emitPendingInputRequest` advertises `itemId ?? requestId` as the card's + // id, and that is the id every answer path routes on. The waiter map must + // be keyed by the same value or the answer lands nowhere and the agent + // waits forever behind a card the user already dismissed. + const cardItemId = request.itemId ?? request.requestId; + + if (acpAutoApprovesPermissions(runtime.dialect, runtime.permissionMode)) { + // Full auto on a dialect ADE could not configure server-side. Answer the + // agent immediately, but still write the card and its resolution into the + // transcript so "auto-approved" is visible rather than invisible. + const allow = pending.options.find((option) => option.kind === "allow_once") + ?? pending.options.find((option) => option.kind === "allow_always") + ?? pending.options[0]; + if (allow) { + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: request.description ?? request.title ?? "Tool call", + detail: { acp: true, provider: runtime.provider, autoApproved: true }, + }); + pending.select(allow.optionId); + emitPendingInputResolved(managed, { + itemId: cardItemId, + decision: "accept", + turnId: pending.turnId, + questions: request.questions, + }); + return; + } + } + + runtime.openPermissionIds.add(cardItemId); + let answered = false; + managed.localPendingInputs.set(cardItemId, { + request, + resolve: (response) => { + // The agent holds one JSON-RPC request open against this card, so it + // must be answered exactly once whichever drain site got here first. + if (answered) return; + answered = true; + runtime.openPermissionIds.delete(cardItemId); + const decision = response.decision ?? "decline"; + if (decision === "cancel") { + pending.cancel(); + return; + } + const chosen = typeof response.responseText === "string" && response.responseText.trim().length + ? response.responseText.trim() + : typeof response.answers?.decision === "string" + ? response.answers.decision + : null; + const matched = chosen ? pending.options.find((option) => option.optionId === chosen) : null; + const fallback = decision === "accept" || decision === "accept_for_session" + ? (decision === "accept_for_session" + ? pending.options.find((option) => option.kind === "allow_always") + ?? pending.options.find((option) => option.kind === "allow_once") + : pending.options.find((option) => option.kind === "allow_once") + ?? pending.options.find((option) => option.kind === "allow_always")) + : pending.options.find((option) => option.kind === "reject_once") + ?? pending.options.find((option) => option.kind === "reject_always"); + const option = matched ?? fallback; + // A decline with no reject option offered is a cancel, not a silent + // approve. Never fail open here. + if (option) pending.select(option.optionId); + else pending.cancel(); + }, + }); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: request.description ?? request.title ?? "Tool call", + detail: { acp: true, provider: runtime.provider }, + }); + }; + + /** + * Open (or reuse) the ACP session backing this chat. + * + * The order of operations is the one `acpHost/index.ts` documents, and it is + * not rearrangeable: dialect, spawn plan, then `openAcpSession`, then persist + * the id the agent minted. Nothing in here writes a provider's config home. + */ + const ensureAcpSessionRuntime = async (managed: ManagedChatSession): Promise => { + const provider = managed.session.provider; + if (!isAcpChatProvider(provider)) { + throw new Error(`Session '${managed.session.id}' is not an ACP chat.`); + } + const dialect = acpDialectFor(provider); + const runtimeEnv = buildAgentRuntimeEnv(managed); + + if (provider === "kimi") { + const preflight = checkKimiWindowsPrerequisites({ env: runtimeEnv }); + if (!preflight.ok) throw new Error(preflight.message); + } + + const cliStatuses = await detectCliAuthStatuses({ skipAuthProbe: true }).catch(() => []); + const cli = cliStatuses.find((entry) => entry.cli === provider) ?? null; + const executable = resolveAcpExecutable(provider, { + env: runtimeEnv, + ...(cli?.path ? { auth: [{ type: "cli-subscription", cli: provider, path: cli.path, authenticated: cli.authenticated, verified: cli.verified }] } : {}), + }); + if (executable.source === "fallback-command" && cli && !cli.installed) { + throw new Error( + `The ${dialect.displayName} CLI (\`${dialect.binaryNames[0]}\`) was not found on this machine. Install it, then refresh AI settings.`, + ); + } + + const configHome = acpConfigHomeFor(provider, runtimeEnv); + const permissionMode = resolveAcpPermissionMode(managed.session); + const modelToken = acpModelTokenFor(managed.session); + const spawnPlan = dialect.buildSpawnPlan({ + binaryPath: executable.path, + cwd: managed.laneWorktreePath, + baseEnv: runtimeEnv, + modelId: modelToken, + reasoningEffort: managed.session.reasoningEffort ?? null, + permissionMode, + configHome, + }); + const invocationKey = acpInvocationKey(spawnPlan); + + const persisted = readPersistedState(managed.session.id); + const existingSessionId = managed.seededAcpSessionId?.trim() + || persisted?.acpSessionId?.trim() + || null; + if (managed.acpSupervisionNoticeShown === undefined) { + managed.acpSupervisionNoticeShown = persisted?.acpSupervisionNoticeShown === true; + } + + // Grok's approval neutralization is a provider-specific pre-session check; + // the coordinator still owns the actual session lifecycle below. + const supervisionPreflight = provider === "grok" && !args.acpSpawnOverride + ? await checkGrokPermissionNeutralization({ spawnPlan, logger }).catch((error) => ({ + ok: false, + detail: error instanceof Error ? error.message : String(error), + })) + : null; + + const existing = managed.runtime; + const runtime = await createAcpRuntime({ + owner: managed, + provider, + dialect, + spawnPlan, + invocationKey, + permissionMode, + modelToken, + existingSessionId, + supervisionPreflight, + supervisionAlreadyNotified: managed.acpSupervisionNoticeShown === true, + mcpServers: [], + logger, + runtimeBudget, + existingRuntime: existing?.kind === "acp" ? existing : null, + runtimeInvalidated: managed.runtimeInvalidated, + hasExistingRuntime: existing !== null, + teardownExistingRuntime: () => teardownRuntime(managed, "handle_close"), + nativeModeValue: acpNativeModeValue(permissionMode), + setResumeCommand: (command) => sessionService.setResumeCommand(managed.session.id, command), + binarySource: executable.source, + ...(args.acpSpawnOverride ? { spawnOverride: args.acpSpawnOverride } : {}), + ...(args.acpSessionPool ? { pool: args.acpSessionPool } : {}), + callbacks: { + onEvents: (runtime, events) => { + if (!runtime || managed.runtime !== runtime) return; + for (const event of events) emitChatEvent(managed, event); + }, + onPermissionRequested: (runtime, pending) => { + if (!runtime) { + pending.cancel(); + return; + } + presentAcpPermissionRequest(managed, runtime, pending); + }, + onPermissionSettled: (runtime, requestId) => { + runtime?.openPermissionIds.delete(requestId); + }, + onSlashCommands: (runtime, commands) => { + if (!runtime || managed.runtime !== runtime) return; + runtime.slashCommands = commands; + }, + onConfigOptions: (runtime, snapshot) => { + if (!runtime) return; + applyAcpConfigOptions(managed, runtime, snapshot); + }, + onSessionInfo: (runtime, info) => { + if (!runtime || managed.runtime !== runtime) return; + adoptRuntimeSessionTitle(managed, info.title, "acp_session_info"); + }, + onProcessExit: (runtime, detail) => { + if (!runtime || managed.runtime !== runtime) return; + runtime.processFailed = true; + runtime.interrupted = true; + managed.runtimeInvalidated = true; + cancelPendingInputsFrom(managed, "acp", "ade"); + logger.warn("agent_chat.acp_process_exit", { + sessionId: managed.session.id, + provider, + code: detail.code, + signal: detail.signal, + stderrTail: detail.stderrTail.slice(-500), + }); + }, + onRuntimeCreated: (runtime) => { + managed.runtime = runtime; + managed.runtimeInvalidated = false; + managed.seededAcpSessionId = runtime.session.sessionId; + managed.session.acpPermissionMode = permissionMode; + }, + onOpenFailed: (error) => { + const message = error instanceof Error ? error.message : String(error); + recordAcpAuthProbeResult( + provider, + managed.laneWorktreePath, + isAcpAuthError(error) + ? { state: "auth-failed", message } + : { state: "runtime-failed", message }, + ); + }, + onReady: () => { + recordAcpAuthProbeResult(provider, managed.laneWorktreePath, { state: "ready", message: null }); + persistChatState(managed); + }, + }, + }); + return runtime; + }; + + const runAcpTurn = async ( + managed: ManagedChatSession, + args: { + promptText: string; + userText?: string; + displayText?: string; + attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; + resolvedAttachments?: ResolvedAgentChatFileRef[]; + metadata?: AgentChatEventMetadata | null | undefined; + laneDirectiveKey?: string | null; + onDispatched?: () => void; + onBackendDispatched?: () => void; + }, + ): Promise => { + const turnId = randomUUID(); + const provider = managed.session.provider; + if (!isAcpChatProvider(provider)) { + throw new Error(`Session '${managed.session.id}' is not an ACP chat.`); + } + const doneModel = { + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }; + + let runtime: AcpRuntime; + try { + runtime = await ensureAcpSessionRuntime(managed); + const validation = validateSessionReadyForTurn(managed); + if (!validation.ready) throw new Error(validation.reason); + } catch (error) { + // Setup failure. One visible error, then the terminal markers that + // release the composer — a chat that never reaches `done` stays stuck + // with no way for the user to see why. + markSessionIdleWithFreshCache(managed); + const message = error instanceof Error ? error.message : String(error); + reportProviderRuntimeFailure(provider, message); + emitChatEvent(managed, { type: "error", message, turnId }); + emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); + emitChatEvent(managed, { type: "done", turnId, status: "failed", ...doneModel }); + appendCtoTurnJournal(managed, { failureNote: `Turn failed: ${message}` }); + persistChatState(managed); + return; + } + + emitAcpDegradationNotes(managed, runtime.dialect); + + runtime.busy = true; + runtime.activeTurnId = turnId; + runtime.interrupted = false; + setSessionActive(managed); + + const attachments = args.attachments ?? []; + const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ + ...attachment, + _resolvedPath: attachment.path, + _rootPath: managed.laneWorktreePath, + })); + const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; + const userText = args.userText?.trim().length ? args.userText.trim() : displayText; + emitPreparedUserMessage(managed, { + text: userText, + displayText, + attachments, + contextAttachments: args.contextAttachments ?? [], + metadata: args.metadata, + turnId, + laneDirectiveKey: args.laneDirectiveKey, + onDispatched: args.onDispatched, + }); + emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); + captureTurnBeforeSha(managed); + emitChatEvent(managed, { type: "activity", ...initialTurnActivity(managed.session), turnId }); + + try { + let prompt = args.promptText; + const pendingContext = consumePendingTurnContextPrefix(managed, false)?.composed; + if (pendingContext) prompt = `${pendingContext}\n\n${prompt}`; + if (!isPersonalSession(managed.session) && managed.lastLaneDirectiveKey !== args.laneDirectiveKey) { + const guidance = buildAdeGuidanceForLane(managed.laneWorktreePath, managed.session); + if (guidance.trim()) prompt = `${guidance}\n\n${prompt}`; + } + + const imagePrompt = runtime.dialect.imagePrompts.declared + ? runtime.dialect.imagePrompts.behavior + : null; + const blocks = await buildAcpPromptBlocks({ + promptText: prompt, + attachments: resolvedAttachments, + agentSupportsImages: runtime.session.connection.initializeResult?.agentCapabilities?.promptCapabilities?.image === true, + imagePrompt, + readAttachmentBytes: readResolvedAttachmentBytes, + }); + const outcome = await runtime.session.prompt({ + turnId, + blocks, + }); + args.onBackendDispatched?.(); + // Usage rides the prompt result for Grok and Copilot, and does not exist + // at all for Kimi. `outcome.events` is empty in the latter case, so no + // usage row is fabricated. + for (const event of outcome.events) emitChatEvent(managed, event); + + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); + markSessionIdleWithFreshCache(managed); + // Client-side cancel accounting. Copilot reports a stopped turn as + // `end_turn`, so the agent's stopReason is never the deciding word. + const interrupted = outcome.interrupted || runtime.interrupted; + if (!interrupted) reportProviderRuntimeReady(provider); + void emitTurnDiffSummaryIfChanged(managed, turnId); + emitChatEvent(managed, { + type: "status", + turnStatus: interrupted ? "interrupted" : "completed", + turnId, + }); + emitChatEvent(managed, { + type: "done", + turnId, + status: interrupted ? "interrupted" : "completed", + ...doneModel, + }); + persistChatState(managed); + } catch (error) { + markSessionIdleWithFreshCache(managed); + const message = error instanceof Error ? error.message : String(error); + void emitTurnDiffSummaryIfChanged(managed, turnId); + if (runtime.interrupted && !runtime.processFailed) { + emitChatEvent(managed, { type: "status", turnStatus: "interrupted", turnId }); + emitChatEvent(managed, { type: "done", turnId, status: "interrupted", ...doneModel }); + } else { + reportProviderRuntimeFailure(provider, message); + emitChatEvent(managed, { type: "error", message, turnId }); + emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); + emitChatEvent(managed, { type: "done", turnId, status: "failed", ...doneModel }); + appendCtoTurnJournal(managed, { failureNote: `Turn failed: ${message}` }); + } + persistChatState(managed); + } finally { + if (managed.runtime === runtime) { + runtime.busy = false; + runtime.activeTurnId = null; + runtime.interrupted = false; + } + // The host publishes the "this agent gates itself" notice at most once, + // and latching it here keeps that promise across a runtime restart. + if (runtime.session.unsupervised && !managed.acpSupervisionNoticeShown) { + managed.acpSupervisionNoticeShown = true; + logger.warn("agent_chat.acp_session_unsupervised", { + sessionId: managed.session.id, + provider, + permissionMode: runtime.permissionMode, + }); + persistChatState(managed); + } + // A permission request that outlives its turn blocks the agent behind a + // card the user can no longer reach. + cancelPendingInputsFrom(managed, "acp"); + + // Permission mode is part of the provider runtime's process/session + // contract. If another client changed it while this turn was running, + // do not deliver a queued steer through the old, more-permissive runtime. + // Preserve the ACP session id so the next turn resumes the conversation + // with the new posture. + if (managed.runtime === runtime && ( + managed.runtimeInvalidated + || runtime.permissionMode !== resolveAcpPermissionMode(managed.session) + )) { + teardownRuntime(managed, "pool_compaction"); + } + } + + if (!managed.closed && managed.runtime === runtime && runtime.pendingSteers.length) { + await deliverNextQueuedSteer(managed, runtime).catch((error) => { + logger.warn("agent_chat.acp_deliver_queued_steer_failed", { + sessionId: managed.session.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + }; + const runPiTurn = async ( managed: ManagedChatSession, args: { @@ -24152,6 +25003,9 @@ export function createAgentChatService(args: { if (runtimeKind === "pi" || managed.session.provider === "pi") { return runPiTurn(managed, args); } + if (runtimeKind === "acp" || isAcpChatProvider(managed.session.provider)) { + return runAcpTurn(managed, args); + } if (runtimeKind !== "opencode") { throw new Error(`Streaming runtime is not available for session '${managed.session.id}'.`); } @@ -31346,7 +32200,7 @@ export function createAgentChatService(args: { const deliverNextQueuedSteer = async ( managed: ManagedChatSession, - runtime: CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime | PiRuntime, + runtime: CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime | PiRuntime | AcpRuntime, ): Promise => { if (managed.closed) return false; // A user-selected priority dispatch owns the staged queue while its SDK @@ -34248,7 +35102,16 @@ export function createAgentChatService(args: { try { const availableModels = await getAvailableModels({ provider: targetProvider, - activateRuntime: targetProvider === "cursor" || targetProvider === "droid" || targetProvider === "opencode", + // Runtimes whose model list is discovered rather than static must be + // woken before the destination check can trust an empty answer. + activateRuntime: + targetProvider === "cursor" + || targetProvider === "droid" + || targetProvider === "opencode" + || targetProvider === "qwen" + || targetProvider === "kimi" + || targetProvider === "grok" + || targetProvider === "copilot", ...(targetProvider === "cursor" ? { cursorSource: "sdk" } : {}), }); modelAvailable = availableModels.some((model) => @@ -40317,6 +41180,28 @@ export function createAgentChatService(args: { return; } + if (isAcpChatProvider(managed.session.provider)) { + if (reasoningEffort !== undefined) { + managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); + } + // A slash command is ordinary prompt text for every ACP dialect: the + // agent advertises the list, ADE offers it, and the chosen text is sent + // unchanged. There is no dispatch verb to translate it into. + await runAcpTurn(managed, { + promptText, + userText: submittedText, + displayText: visibleText, + attachments, + contextAttachments, + resolvedAttachments, + metadata, + laneDirectiveKey, + onDispatched, + onBackendDispatched, + }); + return; + } + if (managed.session.provider === "pi") { if (reasoningEffort !== undefined) { managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort); @@ -42305,6 +43190,44 @@ export function createAgentChatService(args: { return result; } + if (managed.runtime?.kind === "acp") { + const rt = managed.runtime; + // Client-side accounting first. The turn body reads this flag, not the + // agent's stopReason, which Copilot is known to report wrongly and which + // Grok never sends at all because its cancel is a notification. + rt.interrupted = true; + rt.pendingSteers.length = 0; + // Answer the open permission cards before the cancel. `session.cancel` + // does the same for the host's own bridge; this drains ADE's side so a + // card cannot outlive the turn it belongs to. + cancelPendingInputsFrom(managed, "acp", "ade"); + try { + await rt.session.cancel("the user stopped this turn"); + } catch (error) { + logger.warn("agent_chat.acp_interrupt_failed", { + sessionId: managed.session.id, + provider: rt.provider, + error: error instanceof Error ? error.message : String(error), + }); + } + if (mode === "stop_and_clear") cancelQueuedSteers(managed, rt, "interrupted"); + persistChatState(managed); + return result; + } + + if (isAcpChatProvider(managed.session.provider)) { + // The stop landed while the session was still opening, so there is no + // runtime to cancel. Clear the queue and idle the row, the same way the + // Pi no-runtime arm does. + if (mode === "stop_and_clear") { + cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted"); + } + cancelPendingInputsFrom(managed, "acp", "ade"); + setSessionIdle(managed); + persistChatState(managed); + return result; + } + if (managed.runtime?.kind === "droid") { const rt = managed.runtime; rt.interrupted = true; @@ -42698,6 +43621,18 @@ export function createAgentChatService(args: { managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; enforceManagedLocalHarnessPermissionMode(managed); sessionService.setResumeCommand(sessionId, `chat:pi:${sessionId}`); + } else if (isAcpChatProvider(managed.session.provider)) { + // Restore the pointer and the posture before opening: the session id is + // what turns `session/new` into a `session/resume`, so reading it after + // the open would start a second conversation instead of rejoining the + // first. + managed.seededAcpSessionId = persisted?.acpSessionId ?? managed.seededAcpSessionId; + managed.session.acpPermissionMode = persisted?.acpPermissionMode ?? managed.session.acpPermissionMode; + managed.session.acpConfigSnapshot = persisted?.acpConfigSnapshot ?? managed.session.acpConfigSnapshot; + const acpRuntime = await ensureAcpSessionRuntime(managed); + managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; + enforceManagedLocalHarnessPermissionMode(managed); + sessionService.setResumeCommand(sessionId, `chat:${acpRuntime.provider}:${sessionId}`); } else if (managed.session.provider === "cursor") { await ensureCursorSdkRuntime(managed); managed.session.opencodePermissionMode = persisted?.opencodePermissionMode ?? managed.session.opencodePermissionMode; @@ -43495,6 +44430,9 @@ export function createAgentChatService(args: { return typeof permission === "string" && permission.trim().length ? permission : null; } case "pi": + case "acp": + // Both surface their cards through `localPendingInputs`, which the + // check at the top of this function already answered. return null; } }; @@ -44725,6 +45663,12 @@ export function createAgentChatService(args: { runtime.permissionWaiters.clear(); runtime.busy = false; runtime.activeTurnId = null; + } else if (runtime?.kind === "acp") { + // The cards already settled through `localPendingInputs` above, which + // answered the agent. Only the busy bookkeeping is left. + runtime.openPermissionIds.clear(); + runtime.busy = false; + runtime.activeTurnId = null; } } finally { managed.pendingInputSettlementResolvedIds = undefined; @@ -45111,6 +46055,13 @@ export function createAgentChatService(args: { "droid", "lmstudio", "ollama", + // ACP providers refresh on the same TTL. Without an entry here + // `shouldRefreshProvider` has no bookkeeping for them and a force refresh + // could re-probe on every catalog read. + "qwen", + "kimi", + "grok", + "copilot", ]; let modelCatalogCache: AgentChatModelCatalog | null = null; @@ -45383,6 +46334,52 @@ export function createAgentChatService(args: { } } + if (isAcpChatProvider(provider)) { + // Provider rows are gated on a real connection verdict. A passive + // catalog read uses the cached disk heuristic; an active provider + // refresh can perform the one-provider ACP handshake when no verdict is + // cached. That keeps the normal picker read cheap while making an + // explicit refresh capable of recovering keychain-backed logins such as + // Copilot's. + try { + const cliStatuses = await detectCliAuthStatuses({ + skipAuthProbe: !args.activateRuntime, + ...(args.activateRuntime ? { force: true } : {}), + }); + const cli = cliStatuses.find((entry) => entry.cli === provider) ?? null; + if (!cli?.installed) return []; + let health = getProviderRuntimeHealth(provider); + let authReady = health ? health.state === "ready" : cli.authenticated; + if (!authReady && !health && args.activateRuntime) { + const probe = await probeAcpProviderAuth({ provider, cwd: projectRoot, logger }); + health = getProviderRuntimeHealth(provider); + authReady = probe.state === "ready" || health?.state === "ready"; + } + if (!authReady) return []; + if (provider === "qwen") { + const settings = await loadQwenUserSettings(); + if (settings.models.length) { + mergeDynamicAcpModelDescriptors( + "qwen", + settings.models.map((model) => createDynamicAcpModelDescriptor("qwen", model.id, { + displayName: model.displayName, + })), + ); + } + const descriptors = listAcpModelDescriptorsForProvider(provider, { + ...(settings.models.length + ? { configuredModelIds: settings.models.map((model) => model.id) } + : {}), + }); + return descriptors.map((descriptor, index) => acpModelInfoFromDescriptor(descriptor, provider, index)); + } + const descriptors = listAcpModelDescriptorsForProvider(provider); + return descriptors.map((descriptor, index) => acpModelInfoFromDescriptor(descriptor, provider, index)); + } catch { + return []; + } + } + if (provider === "opencode") { try { const effectiveConfig = projectConfigService.get().effective; @@ -45495,11 +46492,24 @@ export function createAgentChatService(args: { "claude", "codex", "cursor", - "droid", - "opencode", "pi", + "copilot", + "grok", + "droid", + "kimi", + "qwen", ] as const satisfies readonly AgentChatProvider[]; + /** + * Providers the user switched off in Settings. + * + * Read fresh on every call rather than captured: the toggle writes through + * `ai.updateConfig` and the next model read has to see it, without a restart + * and without a cache-invalidation dance of its own. + */ + const providerIsDisabled = (candidate: string): boolean => + isProviderDisabled(projectConfigService.get().effective.ai, candidate); + const getAvailableModels = async ({ provider, activateRuntime, @@ -45510,6 +46520,11 @@ export function createAgentChatService(args: { cursorSource?: AgentChatCursorModelSource; } = {}): Promise => { const requestedProvider = provider?.trim() ? provider : undefined; + // The one gate for every model read. The aggregate branch below recurses + // through this same function, so filtering here covers the picker, the + // catalog, the `chat.getAvailableModels` action, and the `providerUsable` + // preflight in one place instead of four. + if (requestedProvider && providerIsDisabled(requestedProvider)) return []; const requestKey = `${requestedProvider ?? "all"}:${activateRuntime === true ? "active" : "passive"}:${cursorSource ?? "all"}`; const existingRequest = availableModelsRequests.get(requestKey); if (existingRequest) { @@ -45559,7 +46574,19 @@ export function createAgentChatService(args: { || shouldRefreshProvider("ollama"); const shouldRefreshPi = shouldRefreshProvider("pi") || (mode === "cached" && !modelCatalogCache); - const catalogProviders: ModelProviderGroup[] = ["claude", "codex", "cursor", "droid", "pi"]; + // Disabled providers drop out before the fan-out, not after: `activateRuntime` + // would otherwise spin up a runtime for a provider the user switched off. + const catalogProviders: ModelProviderGroup[] = ([ + "claude", + "codex", + "cursor", + "pi", + "copilot", + "grok", + "droid", + "kimi", + "qwen", + ] as ModelProviderGroup[]).filter((provider) => !providerIsDisabled(provider)); const modelsByProvider = await Promise.all( catalogProviders.map(async (provider) => { try { @@ -45570,7 +46597,8 @@ export function createAgentChatService(args: { activateRuntime: (provider === "cursor" && shouldRefreshProvider("cursor")) || (provider === "droid" && shouldRefreshProvider("droid")) - || (provider === "pi" && shouldRefreshPi), + || (provider === "pi" && shouldRefreshPi) + || (isAcpChatProvider(provider) && shouldRefreshProvider(provider)), ...(provider === "cursor" && catalogArgs?.cursorSource ? { cursorSource: catalogArgs.cursorSource } : {}), @@ -45689,7 +46717,13 @@ export function createAgentChatService(args: { } const opencodeProviderById = new Map(opencodeInventory.providers.map((provider) => [provider.id, provider])); - const blocks = buildProviderGroupBlocks(descriptors, createModelOrderMap(), opencodeInventory.providers); + // Curated descriptors are in the registry whether or not a provider is + // installed, so a disabled provider would still produce a full group block + // here. Drop the whole group: the catalog is what the picker, the phone, + // and the relay all read, and "switched off" has to mean the same thing on + // each of them. + const blocks = buildProviderGroupBlocks(descriptors, createModelOrderMap(), opencodeInventory.providers) + .filter((group) => !providerIsDisabled(group.key)); const catalog: AgentChatModelCatalog = { fetchedAt: nowIso(), @@ -46279,6 +47313,7 @@ export function createAgentChatService(args: { droidPermissionMode, cursorModeId, cursorConfigValues, + acpPermissionMode: requestedAcpPermissionMode, permissionMode, spawnKind: requestedSpawnKind, subagentTakeoverPromptShown, @@ -46290,6 +47325,9 @@ export function createAgentChatService(args: { const identityPinned = isPrimaryPinnedIdentity(managed.session.identityKey); const orchestrationLockedMode = lockedOrchestrationPermissionMode(managed.session); const permissionsPinned = identityPinned || orchestrationLockedMode !== null; + const previousAcpPermissionMode = isAcpChatProvider(managed.session.provider) + ? resolveAcpPermissionMode(managed.session) + : null; const prevCodexApprovalPolicy = managed.session.codexApprovalPolicy; const prevCodexSandbox = managed.session.codexSandbox; const prevCodexConfigSource = managed.session.codexConfigSource; @@ -46310,7 +47348,8 @@ export function createAgentChatService(args: { || opencodePermissionMode !== undefined || droidPermissionMode !== undefined || cursorModeId !== undefined - || cursorConfigValues !== undefined; + || cursorConfigValues !== undefined + || requestedAcpPermissionMode !== undefined; let modelHandoff: AgentChatModelHandoff | null = null; if (modelId !== undefined) { @@ -46755,6 +47794,32 @@ export function createAgentChatService(args: { await syncLiveCursorSdkPolicy(managed, managed.runtime, "session_update"); } } + + if (isAcpChatProvider(managed.session.provider) && !permissionsPinned) { + if (requestedAcpPermissionMode !== undefined) { + managed.session.acpPermissionMode = requestedAcpPermissionMode; + } else if (permissionMode !== undefined) { + // `acpPermissionMode` is a latched native value. A generic composer + // update is an explicit user choice, so it must replace any value + // captured when the current ACP runtime was opened. + managed.session.acpPermissionMode = acpPermissionModeFromLegacyPermissionMode( + managed.session.permissionMode, + ); + } + } + + const acpPermissionModeChanged = isAcpChatProvider(managed.session.provider) + && previousAcpPermissionMode !== resolveAcpPermissionMode(managed.session); + if (acpPermissionModeChanged && managed.runtime?.kind === "acp") { + if (managed.runtime.busy) { + // Let the current turn finish under the mode it was opened with. The + // ACP turn finalizer tears this runtime down before any queued steer + // can reuse it; the next turn resumes with the new mode. + managed.runtimeInvalidated = true; + } else { + teardownRuntime(managed, "pool_compaction"); + } + } // Broadcast a transient meta patch so other clients (desktop refreshing a // session an iOS device just re-moded, or vice versa) update their composer // controls immediately. Emitted after the cursor policy sync above so @@ -46778,6 +47843,9 @@ export function createAgentChatService(args: { ...(cursorConfigValues !== undefined || managed.session.cursorConfigValues != null ? { cursorConfigValues: managed.session.cursorConfigValues ?? null } : {}), + ...(managed.session.acpPermissionMode !== undefined + ? { acpPermissionMode: managed.session.acpPermissionMode } + : {}), }); } if ( @@ -47047,6 +48115,24 @@ export function createAgentChatService(args: { return mergeSlashCommands([cursorCommands, localCommands]); } + if (isAcpChatProvider(provider)) { + // The agent advertises its own list through `available_commands_update`, + // and the dialect has already filtered out the commands only its terminal + // UI can run — Copilot's `/diff`, `/resume`, `/login` and friends would + // otherwise reach the model as prose and waste a turn. Grok re-sends its + // list on almost every turn, so `mergeSlashCommands` dedupes by name. + const runtimeCommands: AgentChatSlashCommand[] = + managed?.runtime?.kind === "acp" + ? managed.runtime.slashCommands.map((command) => ({ + name: command.name.startsWith("/") ? command.name : `/${command.name}`, + description: command.description, + ...(command.inputHint ? { argumentHint: command.inputHint } : {}), + source: "sdk" as const, + })) + : []; + return mergeSlashCommands([filesystemBackedCommands(), localCommands, runtimeCommands]); + } + // Droid and OpenCode can both use the same filesystem-backed prompt and // skill list even when their native runtimes do not auto-list it. return mergeSlashCommands([filesystemBackedCommands(), localCommands]); diff --git a/apps/desktop/src/main/services/config/projectConfigService.test.ts b/apps/desktop/src/main/services/config/projectConfigService.test.ts index a8942532d5..bc4a6279a0 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.test.ts @@ -866,6 +866,22 @@ describe("projectConfigService - AI mode migration", () => { expect(cleared?.customProviders).toBeUndefined(); expect(cleared?.customModelSlugs).toBeUndefined(); }); + + // The Settings toggle writes the whole authoritative list. Under union + // semantics re-enabling a provider would be inexpressible, which is the same + // trap custom providers fell into above. + it("replaces the disabled-provider list so a provider can be switched back on", () => { + const shared = { disabledProviders: ["grok", "copilot"] }; + + const narrowed = mergeAiConfig(shared, { disabledProviders: ["grok"] }); + expect(narrowed?.disabledProviders).toEqual(["grok"]); + + const kept = mergeAiConfig(shared, { defaultModel: "openai/gpt-5.4" }); + expect(kept?.disabledProviders).toEqual(["grok", "copilot"]); + + const cleared = mergeAiConfig(shared, { disabledProviders: [] }); + expect(cleared?.disabledProviders).toBeUndefined(); + }); }); describe("projectConfigService - PR transcript gists", () => { diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index f08a707588..653e5fe24d 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -1451,9 +1451,17 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { const providersRaw = isRecord(permissionsRaw.providers) ? permissionsRaw.providers : null; if (providersRaw) { const providers: NonNullable["providers"]> = {}; - const providerMode = (key: "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi") => { + const providerMode = ( + key: "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi" | "qwen" | "kimi" | "grok" | "copilot", + ) => { const mode = asString(providersRaw[key])?.trim(); - if (mode === "default" || mode === "plan" || mode === "edit" || mode === "full-auto" || mode === "config-toml") { + // "auto" belongs here: it is a member of `AgentChatPermissionMode` and + // the ACP providers offer it by name, so dropping it would let the + // picker show a choice the config layer refuses to keep. + if ( + mode === "default" || mode === "plan" || mode === "edit" + || mode === "auto" || mode === "full-auto" || mode === "config-toml" + ) { providers[key] = mode; } }; @@ -1463,6 +1471,10 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { providerMode("droid"); providerMode("opencode"); providerMode("pi"); + providerMode("qwen"); + providerMode("kimi"); + providerMode("grok"); + providerMode("copilot"); const codexSandbox = asString(providersRaw.codexSandbox)?.trim(); if (codexSandbox === "read-only" || codexSandbox === "workspace-write" || codexSandbox === "danger-full-access") { providers.codexSandbox = codexSandbox; @@ -1630,6 +1642,14 @@ function coerceAiConfig(value: unknown): AiConfig | undefined { .filter(Boolean); if (customModelSlugs?.length) out.customModelSlugs = customModelSlugs; + // Ids are lower-cased but not validated against today's provider list: this + // field crosses the sync wire, and dropping an id a newer build wrote would + // silently re-enable a provider the user switched off on another machine. + const disabledProviders = asStringArray(value.disabledProviders) + ?.map((provider) => provider.trim().toLowerCase()) + .filter(Boolean); + if (disabledProviders?.length) out.disabledProviders = [...new Set(disabledProviders)]; + const workerSafety = coerceWorkerSafetyPolicy(value.workerSafety); if (workerSafety) out.workerSafety = workerSafety; @@ -1987,6 +2007,10 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): // would make removals impossible to persist. Absent = keep, [] = clear. const customProviders = localAi?.customProviders ?? sharedAi?.customProviders ?? []; const customModelSlugs = localAi?.customModelSlugs ?? sharedAi?.customModelSlugs ?? []; + // Same replace semantics, for the same reason: the toggle writes the whole + // authoritative list, so re-enabling a provider has to be expressible as a + // shorter list rather than as an absence a union would ignore. + const disabledProviders = localAi?.disabledProviders ?? sharedAi?.disabledProviders ?? []; const localProvidersEntries = (["ollama", "lmstudio"] as const) .map((provider) => { const mergedProvider = { @@ -2015,6 +2039,7 @@ export function mergeAiConfig(sharedAi?: AiConfig, localAi?: Partial): ...(Object.keys(apiKeys).length ? { apiKeys } : {}), ...(customProviders.length ? { customProviders } : {}), ...(customModelSlugs.length ? { customModelSlugs } : {}), + ...(disabledProviders.length ? { disabledProviders } : {}), ...(localProvidersEntries.length ? { localProviders } : {}), ...(workerSafety ? { workerSafety } : {}), }; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index caff5f132c..d7dcbcb441 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -67,12 +67,13 @@ import { convertHeicBufferToJpeg, HeicAttachmentConversionError, } from "../chat/heicAttachmentConverter"; -import type { ConvertImageToJpegResult } from "../../../shared/types/chat"; +import type { AcpChatProvider, ConvertImageToJpegResult } from "../../../shared/types/chat"; import { appendEvent as perfAppend, isRunActive as isPerfRunActive } from "../perf/perfLog"; import { buildPrAiResolutionContextKey, isAdeUsageRangePreset, isAdeUsageScope } from "../../../shared/types"; import { detectCliAuthStatuses } from "../ai/authDetector"; import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { buildProviderConnections } from "../ai/providerConnectionStatus"; +import { collectAcpProviderDiagnostics } from "../ai/acpProviderDiagnostics"; import { resolvePiInstallation } from "../ai/piInstallation"; import { pathsEqual } from "../shared/pathCompare"; import { browseProjectDirectories } from "../projects/projectBrowserService"; @@ -589,6 +590,7 @@ import type { AiDetectedAuth, AiFeatureKey, AiProviderConnections, + AcpProviderDiagnostics, AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, @@ -5088,6 +5090,24 @@ export function registerIpc({ }, ); + ipcMain.handle( + IPC.aiAcpProviderDiagnostics, + async ( + _event, + arg: { provider: AcpChatProvider; runDoctor?: boolean }, + ): Promise => { + const ctx = getCtx(); + return await collectAcpProviderDiagnostics({ + provider: arg.provider, + // Diagnostics are about this machine's install, so the project root is + // the right directory: probe verdicts are cached per `{provider, cwd}` + // and a lane worktree would key a second, emptier cache entry. + cwd: ctx.project.rootPath, + ...(arg.runDoctor === true ? { runDoctor: true } : {}), + }); + }, + ); + ipcMain.handle(IPC.aiUpdateConfig, async (_event, partial: Partial): Promise => { const ctx = getCtx(); requireAppContextServices(ctx, ["projectConfigService"] as const); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 2ba3cd71ab..bd09490374 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -301,7 +301,15 @@ async function managedTreeBytes(targetPath: string): Promise { async function hasSymlinkInManagedPath(rootPath: string, targetPath: string): Promise { const root = normAbs(rootPath); const target = normAbs(targetPath); - const relative = path.relative(root, target); + // `path.relative` is lexical on POSIX, even when the underlying macOS + // volume is case-insensitive. Resolve existing ancestors for the containment + // calculation so ADE's and Git's spellings of the same managed folder do not + // look like an escape. Keep the original spellings below so lstat still + // catches a symlink in the path rather than hiding it behind realpath. + const relative = path.relative( + stablePathThroughExistingAncestor(root), + stablePathThroughExistingAncestor(target), + ); if (relative.startsWith("..") || path.isAbsolute(relative)) return true; const segments = relative ? relative.split(path.sep) : []; let candidate = root; diff --git a/apps/desktop/src/main/services/lanes/laneStorageLifecycle.test.ts b/apps/desktop/src/main/services/lanes/laneStorageLifecycle.test.ts index 21de9e65a9..959f1178ab 100644 --- a/apps/desktop/src/main/services/lanes/laneStorageLifecycle.test.ts +++ b/apps/desktop/src/main/services/lanes/laneStorageLifecycle.test.ts @@ -642,7 +642,23 @@ describe("lane storage lifecycle", () => { * spellings really are different directories, so the case runs only where the * platform agrees they are not. */ -const itOnCaseInsensitiveFs = it.runIf(process.platform === "win32" || process.platform === "darwin"); +function isCaseInsensitiveFilesystem(): boolean { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-case-sensitivity-probe-")); + try { + const original = path.join(root, "CaseProbe"); + fs.mkdirSync(original); + return fs.existsSync(path.join(root, "caseprobe")); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +// The same OS can be configured with either a case-sensitive or +// case-insensitive volume (macOS commonly has both), so platform alone is not +// enough to decide whether the spelling-divergence scenario is valid. +const itOnCaseInsensitiveFs = it.runIf( + (process.platform === "win32" || process.platform === "darwin") && isCaseInsensitiveFilesystem(), +); describe("reclaim accepts either spelling of the managed worktrees folder", () => { async function caseDivergentFixture() { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index a1920dadc5..2af019cb1d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -1524,6 +1524,37 @@ describe("ptyService", () => { expect(resolveBuiltInBrowserActorCapability(actorToken)).toBeNull(); }); + // The ACP CLIs joined `TrackedAgentCliToolType` before the runtime predicate + // caught up, and the browser-actor token is issued unconditionally — so a + // qwen/kimi/grok/copilot terminal used to leave a live capability behind + // after its session closed. + it.each(["qwen", "kimi", "grok", "copilot"] as const)( + "revokes the browser actor capability when a %s session closes", + async (toolType) => { + const { service, loadPty } = createHarness(); + + const result = await service.create({ + laneId: "lane-1", + title: `${toolType} CLI`, + cols: 80, + rows: 24, + toolType, + command: toolType, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const opts = ptyLib.spawn.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + const actorToken = opts?.env?.ADE_BROWSER_ACTOR_TOKEN; + expect(resolveBuiltInBrowserActorCapability(actorToken)).toMatchObject({ + chatSessionId: result.sessionId, + }); + + service.dispose({ ptyId: result.ptyId, sessionId: result.sessionId }); + + expect(resolveBuiltInBrowserActorCapability(actorToken)).toBeNull(); + }, + ); + it("exports spawn lineage without replacing the tracked CLI session identity", async () => { const { service, loadPty } = createHarness(); @@ -1659,6 +1690,29 @@ describe("ptyService", () => { expectNoJargon(refusal); }); + // Same gate, for the CLIs that used to slip past the predicate entirely. + it.each(["qwen", "kimi", "grok", "copilot"] as const)( + "refuses a new %s launch when storage is exhausted", + async (toolType) => { + const canPerform = vi.fn(() => ({ + allowed: false, + state: "exhausted", + code: "disk_full", + message: "Your computer is almost out of storage. ADE can't safely start a new CLI session until you free up space.", + })); + const { service } = createHarness({ diskPressureMonitor: { canPerform } }); + + await expect(service.create({ + laneId: "lane-1", + title: `${toolType} CLI`, + cols: 80, + rows: 24, + toolType, + command: toolType, + })).rejects.toMatchObject({ code: "disk_full" }); + }, + ); + it("does not leak an inherited ADE chat session into unlinked terminals", async () => { const previous = process.env.ADE_CHAT_SESSION_ID; process.env.ADE_CHAT_SESSION_ID = "outer-chat"; diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 8977905913..2a7ae8b65f 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -137,7 +137,8 @@ import { claudeAgentSkillPluginRoots } from "../skills/agentSkillRuntimeService" import { stripAnsi } from "../../utils/ansiStrip"; import { summarizeTerminalSession } from "../../utils/sessionSummary"; import { derivePreviewFromChunk, type PreviewCursorState } from "../../utils/terminalPreview"; -import { claudeConfigHome, codexConfigHome, factoryConfigHome } from "../shared/providerConfigHomes"; +import { claudeConfigHome, codexConfigHome, factoryConfigHome, kimiCodeConfigHome } from "../shared/providerConfigHomes"; +import { checkKimiWindowsPrerequisites } from "../ai/acpExecutables"; import { clearTuiWaitingInput, createTuiMarkerState, @@ -1343,7 +1344,12 @@ function runtimeFromStatus(status: TerminalSessionStatus): TerminalRuntimeState function normalizeToolType(raw: unknown): TerminalToolType | null { const value = typeof raw === "string" ? raw.trim().toLowerCase() : ""; if (!value) return null; - const allowed: TerminalToolType[] = [ + // Every member of `TerminalToolType`. The `satisfies` binding is the point: + // a tool type added to the union but missed here silently normalises to + // "other", which strips the session of every tracked-CLI behaviour — turn + // markers, resume capture, the disk-pressure gate, and the browser-actor + // capability revoke — with nothing to show for it. + const allowed = [ "shell", "claude", "codex", @@ -1360,11 +1366,19 @@ function normalizeToolType(raw: unknown): TerminalToolType | null { "pi-chat", "cursor", "droid-chat", + "qwen", + "kimi", + "grok", + "copilot", + "qwen-chat", + "kimi-chat", + "grok-chat", + "copilot-chat", "aider", "continue", - "other" - ]; - return (allowed as string[]).includes(value) ? (value as TerminalToolType) : "other"; + "other", + ] as const satisfies readonly TerminalToolType[]; + return (allowed as readonly string[]).includes(value) ? (value as TerminalToolType) : "other"; } /** Extract --session-id from a Claude startup command if present. */ @@ -3883,6 +3897,12 @@ export function createPtyService({ const CLAUDE_TITLE_POLL_DELAYS_MS = [1_000, 2_500, 5_000, 12_000, 30_000, 60_000]; const CODEX_LIVE_CAPTURE_HARD_TIMEOUT_MS = 60_000; const CODEX_WATCH_DEBOUNCE_MS = 200; + // Kimi writes its session file once the first exchange lands, which is later + // than Codex writes `session_meta`, so the tail of the schedule reaches + // further out. + const KIMI_FALLBACK_POLL_DELAYS_MS = [1_000, 3_000, 8_000, 20_000, 45_000]; + const KIMI_LIVE_CAPTURE_HARD_TIMEOUT_MS = 90_000; + const KIMI_LIVE_CAPTURE_MAX_START_DELTA_MS = 120_000; const adoptClaudeRuntimeTitle = ( sessionId: string, @@ -4169,6 +4189,172 @@ export function createPtyService({ }, CODEX_LIVE_CAPTURE_HARD_TIMEOUT_MS)); }; + const listOtherAdoptedKimiTargetIds = (sessionId: string): Set => { + const adoptedIds = new Set(); + for (const candidate of sessionService.list({ limit: null })) { + if (candidate.id === sessionId) continue; + const targetId = resumeTargetIdForProvider(candidate, "kimi"); + if (targetId) adoptedIds.add(targetId); + } + return adoptedIds; + }; + + /** + * Read a working directory out of a Kimi session file, when it names one. + * + * Kimi's on-disk layout is not a documented contract, so this looks for the + * keys a session record plausibly uses and returns null rather than guessing. + * A null means ownership falls back to the launch window and the exclusion + * set, exactly as it does for a Codex build that ignores the originator. + */ + const readKimiSessionCwd = (filePath: string): string | null => { + const text = readFilePrefix(filePath, 64 * 1024); + if (!text) return null; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + try { + const record = JSON.parse(trimmed) as Record; + for (const key of ["cwd", "workingDirectory", "working_dir", "workdir", "root"]) { + const value = record[key]; + if (typeof value === "string" && value.trim().length) return path.resolve(value.trim()); + } + } catch { + // A partial or non-JSON line tells us nothing. Keep scanning. + } + } + return null; + }; + + /** + * Adopt the session id Kimi minted for a tracked terminal launch. + * + * Kimi cannot be handed a session id at launch — unlike Claude's + * `--session-id` or Grok's `-s` — so the only handle on the conversation is + * the file the CLI writes into its own sessions directory. Same shape as + * `scheduleCodexSessionIdCaptureBestEffort`, with the same ownership layers: + * + * 1. the session record's own cwd, when the file names one; + * 2. a narrow launch window around this PTY's start; + * 3. exclusion of ids already adopted by any other terminal row. + * + * Layer 1 is best effort because Kimi's file layout is not a published + * contract. A capture that cannot prove ownership is skipped, never guessed: + * resuming the wrong conversation is worse than offering no resume at all. + */ + const scheduleKimiSessionIdCaptureBestEffort = ( + sessionId: string, + cwd: string, + startedAt: string, + ): void => { + const startedAtMs = Date.parse(startedAt); + const startedAtFinite = Number.isFinite(startedAtMs) ? startedAtMs : null; + const sessionsBase = path.join(kimiCodeConfigHome({ homeDir: os.homedir() }), "sessions"); + const resolvedCwd = path.resolve(cwd); + let captured = false; + const timers = new Set(); + + const cleanup = (): void => { + captured = true; + for (const timer of timers) clearTimeout(timer); + timers.clear(); + }; + + const tryResolve = (attempt: number): boolean => { + if (captured) return true; + const session = sessionService.get(sessionId); + if (!session) { + cleanup(); + return true; + } + if (sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null)) { + cleanup(); + return true; + } + let excludedIds: Set; + try { + excludedIds = listOtherAdoptedKimiTargetIds(sessionId); + } catch (err) { + // Capturing nothing is safer than adopting an id when the cross-session + // ownership check could not run. + logger.warn("pty.kimi_session_id_exclusion_query_failed", { sessionId, attempt, err: String(err) }); + return false; + } + + let entries: string[]; + try { + entries = fs.readdirSync(sessionsBase); + } catch { + return false; + } + + type KimiCandidate = { id: string; filePath: string; mtimeMs: number; cwdMatched: boolean }; + const candidates: KimiCandidate[] = []; + for (const entry of entries) { + // Ids are ULID shaped. Anything else in the directory is not a session. + const id = entry.replace(/\.(jsonl?|ndjson)$/i, ""); + if (!/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(id)) continue; + if (excludedIds.has(id)) continue; + const entryPath = path.join(sessionsBase, entry); + let stat: fs.Stats; + try { + stat = fs.statSync(entryPath); + } catch { + continue; + } + if (startedAtFinite !== null) { + // A file written before this PTY started belongs to an earlier run. + if (stat.mtimeMs < startedAtFinite - 1_000) continue; + if (stat.mtimeMs - startedAtFinite > KIMI_LIVE_CAPTURE_MAX_START_DELTA_MS) continue; + } + const filePath = stat.isDirectory() ? path.join(entryPath, "session.jsonl") : entryPath; + const recordedCwd = readKimiSessionCwd(filePath); + if (recordedCwd && recordedCwd !== resolvedCwd) continue; + candidates.push({ id, filePath, mtimeMs: stat.mtimeMs, cwdMatched: recordedCwd !== null }); + } + if (!candidates.length) return false; + + // A candidate that proved its cwd always outranks one that could not. + candidates.sort((a, b) => { + if (a.cwdMatched !== b.cwdMatched) return a.cwdMatched ? -1 : 1; + if (startedAtFinite === null) return b.mtimeMs - a.mtimeMs; + return Math.abs(a.mtimeMs - startedAtFinite) - Math.abs(b.mtimeMs - startedAtFinite); + }); + const best = candidates[0]; + if (!best) return false; + + captured = true; + sessionService.setResumeCommand(sessionId, `kimi -S ${best.id}`); + logger.info("pty.kimi_session_id_captured_live", { + sessionId, + kimiSessionId: best.id, + attempt, + ownership: best.cwdMatched ? "session-cwd" : "window", + }); + cleanup(); + return true; + }; + + if (tryResolve(0)) return; + + for (let i = 0; i < KIMI_FALLBACK_POLL_DELAYS_MS.length; i++) { + const attempt = i + 1; + const timer = setTimeout(() => { + try { + if (!captured) tryResolve(attempt); + } catch (err) { + logger.warn("pty.kimi_session_id_capture_failed", { sessionId, attempt, err: String(err) }); + } + }, KIMI_FALLBACK_POLL_DELAYS_MS[i]); + timer.unref?.(); + timers.add(timer); + } + + const hardTimeout = setTimeout(() => cleanup(), KIMI_LIVE_CAPTURE_HARD_TIMEOUT_MS); + hardTimeout.unref?.(); + timers.add(hardTimeout); + }; + const flushPendingPtyOutput = (entry: PtyEntry): void => { const trailing = takeCanonicalPtyOutput(entry, "", true); if (trailing) entry.processOutputData?.(trailing); @@ -5592,6 +5778,14 @@ export function createPtyService({ throw Object.assign(new Error(decision.message), { code: decision.code }); } } + if (toolTypeHint === "kimi") { + // Kimi's native binary runs its commands through Git Bash, so on + // Windows it cannot start at all without Git for Windows. Failing here + // with the install link beats a terminal that opens and dies with an + // unrecognisable shell error. + const preflight = checkKimiWindowsPrerequisites(); + if (!preflight.ok) throw new Error(preflight.message); + } const requestedStartupCommandRaw = typeof effectiveArgs.startupCommand === "string" ? effectiveArgs.startupCommand.trim() : ""; let requestedStartupCommand = requestedStartupCommandRaw; if ( @@ -6749,6 +6943,11 @@ export function createPtyService({ codexLaunchOriginator, ); } + if (!existingSession && toolTypeHint === "kimi" && cwd) { + // Kimi takes no session id at launch, so the id has to be adopted from + // the file it writes. Fresh launches only: a resume already knows its id. + scheduleKimiSessionIdCaptureBestEffort(sessionId, cwd, startedAt); + } if (isClaudeTrackedCliToolType(toolTypeHint) && cwd) { scheduleClaudeRuntimeTitleCaptureBestEffort( sessionId, diff --git a/apps/desktop/src/main/services/shared/providerConfigHomes.ts b/apps/desktop/src/main/services/shared/providerConfigHomes.ts index c21f7845df..69f699fcf0 100644 --- a/apps/desktop/src/main/services/shared/providerConfigHomes.ts +++ b/apps/desktop/src/main/services/shared/providerConfigHomes.ts @@ -70,3 +70,30 @@ export function factoryConfigHome(args: HomeArg = {}): string { const configured = trimmed((args.env ?? process.env).FACTORY_HOME_OVERRIDE); return path.join(configured ? path.resolve(configured) : baseHome(args), ".factory"); } + +/** `QWEN_HOME` names the config directory itself (CODEX_HOME shape). */ +export function qwenConfigHome(args: HomeArg = {}): string { + const configured = trimmed((args.env ?? process.env).QWEN_HOME); + return configured ? path.resolve(configured) : path.join(baseHome(args), ".qwen"); +} + +/** `COPILOT_HOME` names the config directory itself; `--config-dir` is its flag twin. */ +export function copilotConfigHome(args: HomeArg = {}): string { + const configured = trimmed((args.env ?? process.env).COPILOT_HOME); + return configured ? path.resolve(configured) : path.join(baseHome(args), ".copilot"); +} + +/** `KIMI_CODE_HOME` names the config directory itself; it holds `config.toml`. */ +export function kimiCodeConfigHome(args: HomeArg = {}): string { + const configured = trimmed((args.env ?? process.env).KIMI_CODE_HOME); + return configured ? path.resolve(configured) : path.join(baseHome(args), ".kimi-code"); +} + +/** + * Grok has NO config-home override: it reads `~/.grok` and nothing else. ADE + * therefore sets nothing and reuses whatever the user already has. Stated here + * so the absence reads as a decision rather than an omission. + */ +export function grokConfigHome(args: HomeArg = {}): string { + return path.join(baseHome(args), ".grok"); +} diff --git a/apps/desktop/src/main/utils/terminalTuiMarkers.ts b/apps/desktop/src/main/utils/terminalTuiMarkers.ts index 0593e7ab78..deb3c2689e 100644 --- a/apps/desktop/src/main/utils/terminalTuiMarkers.ts +++ b/apps/desktop/src/main/utils/terminalTuiMarkers.ts @@ -142,6 +142,17 @@ const PI_PACK: MarkerPack = { working: [ESC_TO_INTERRUPT], }; +/** + * Generic pack for the ACP CLIs. Only the interrupt hint is claimed: it is the + * one footer every one of them prints, and a guessed prompt pattern would latch + * a row as "waiting on you" that nobody is waiting on. + */ +const ACP_PACK: MarkerPack = { + planning: [], + waitingInput: [NUMBERED_YES_OPTION, YES_NO_PROMPT], + working: [ESC_TO_INTERRUPT], +}; + const PACKS: Record = { claude: CLAUDE_PACK, codex: CODEX_PACK, @@ -149,6 +160,13 @@ const PACKS: Record = { droid: DROID_PACK, opencode: OPENCODE_PACK, pi: PI_PACK, + // ACP providers. ADE reads their turn state off the protocol, not off the + // TUI, so a tracked terminal for one of them gets the generic pack rather + // than invented regexes for footers nobody has measured. + qwen: ACP_PACK, + kimi: ACP_PACK, + grok: ACP_PACK, + copilot: ACP_PACK, }; export type TuiMarkerState = { diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 83102a0c47..35830b573c 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -245,6 +245,7 @@ import type { BudgetCapScope, BudgetCapProvider, BudgetCapConfig, + AcpProviderDiagnostics, AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, @@ -1104,6 +1105,14 @@ declare global { listApiKeys: () => Promise; verifyApiKey: (provider: string) => Promise; updateConfig: (config: Partial) => Promise; + /** + * Optional: shipped after this group did, so an older preload will not + * have it and callers must guard before reaching for it. + */ + acpProviderDiagnostics?: (args: { + provider: "qwen" | "kimi" | "grok" | "copilot"; + runDoctor?: boolean; + }) => Promise; opencodeAuthMethods: () => Promise<{ methods: OpenCodeProviderAuthMethods }>; opencodeOAuthStart: (args: { providerId: string; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7b08f9c9a6..cfdcfcb12b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -133,6 +133,7 @@ import type { AdeActionRegistryEntry, AdeCliInstallResult, AdeCliStatus, + AcpProviderDiagnostics, AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, @@ -4550,6 +4551,14 @@ const adeBridge = { () => ipcRenderer.invoke(IPC.aiUpdateConfig, config), ), ), + acpProviderDiagnostics: async (args: { + provider: "qwen" | "kimi" | "grok" | "copilot"; + runDoctor?: boolean; + }): Promise => + // Deliberately not routed through a project runtime action: this reports + // on the CLIs installed on the machine the main process runs on, and a + // remote host's answer would describe the wrong computer. + ipcRenderer.invoke(IPC.aiAcpProviderDiagnostics, args), opencodeAuthMethods: async (): Promise<{ methods: OpenCodeProviderAuthMethods }> => callProjectRuntimeActionOr("ai", "opencodeAuthMethods", {}, () => ipcRenderer.invoke(IPC.aiOpencodeAuthMethods), diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index 2af40b3796..3d0f5da29c 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -32,6 +32,7 @@ import { ProductAnalyticsSection } from "../settings/ProductAnalyticsSection"; import { DiagnosticsSharingSection } from "../settings/DiagnosticsSharingSection"; import { ProjectSection } from "../settings/ProjectSection"; import { ProvidersSection } from "../settings/ProvidersSection"; +import { providerDescriptor } from "../settings/providers/descriptors"; import { SecretsSection } from "../settings/SecretsSection"; import { SessionLifecycleSection } from "../settings/SessionLifecycleSection"; import { StorageSection } from "../settings/StorageSection"; @@ -124,6 +125,71 @@ function WebNoMachineNotice() { ); } +/** `#ai-provider-` — the deeplink form of one provider's page. */ +const PROVIDER_ANCHOR_PREFIX = "ai-provider-"; + +function providerIdFromHash(hash: string): string | null { + let raw = hash.replace(/^#/, ""); + try { + raw = decodeURIComponent(raw); + } catch { + // A malformed hash should never break the settings page. + } + if (!raw.startsWith(PROVIDER_ANCHOR_PREFIX)) return null; + return raw.slice(PROVIDER_ANCHOR_PREFIX.length) || null; +} + +/** + * Agents & Models. One provider's page is a sub-view of this tab rather than a + * route of its own: `?provider=`, with `#ai-provider-` accepted so the + * manifest entry for each provider deeplinks straight to it. While a provider + * is open the tab shows only that page — the background helpers and voice + * settings below it are not part of the provider you drilled into. + */ +function AgentsTabContent() { + const location = useLocation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const requested = searchParams.get("provider")?.trim() || providerIdFromHash(location.hash); + const providerId = requested && providerDescriptor(requested) ? requested : null; + + const handleProviderChange = useCallback((next: string | null) => { + const nextParams = new URLSearchParams(searchParams); + if (next) nextParams.set("provider", next); + else nextParams.delete("provider"); + navigate( + { + pathname: location.pathname, + search: `?${nextParams.toString()}`, + hash: next ? `#${PROVIDER_ANCHOR_PREFIX}${next}` : "", + }, + { replace: true }, + ); + }, [location.pathname, navigate, searchParams]); + + if (providerId) { + return ( + + + + ); + } + + return ( + <> + + + + + + + + + + + ); +} + /** * Sections, each declaring which manifest settings it holds. On the desktop * `WebSettingsSection` is a passthrough and this renders exactly as it always @@ -166,19 +232,7 @@ function TabContent({ tab }: { tab: SettingsTabId }) { ); case "agents": - return ( - <> - - - - - - - - - - - ); + return ; case "lanes-git": return ( <> diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 03c4e52516..a4f8446604 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -71,6 +71,7 @@ vi.mock("@lobehub/icons", () => { OpenAI: brand(), OpenCode: brand(), OpenRouter: brand(), + Qwen: brand(), XAI: brand(), }; }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 41a982c265..3e2fbdb752 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -40,6 +40,7 @@ vi.mock("@lobehub/icons", () => { Cursor: brand(), OpenCode: brand(), GithubCopilot: brand(), + Qwen: brand(), }; }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 3b9a5108a2..4e65b90189 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -141,6 +141,7 @@ vi.mock("@lobehub/icons", () => { OpenAI: brand(), OpenCode: brand(), OpenRouter: brand(), + Qwen: brand(), XAI: brand(), }; }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index b108990d29..67eb574243 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1639,7 +1639,17 @@ function defaultNativeControls(profile: ChatSurfaceProfile): NativeControlState }; } -type ChatRuntimeProviderKey = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi"; +type ChatRuntimeProviderKey = + | "claude" + | "codex" + | "cursor" + | "droid" + | "opencode" + | "pi" + | "qwen" + | "kimi" + | "grok" + | "copilot"; function resolveChatRuntimeProvider(desc: ModelDescriptor | null | undefined): ChatRuntimeProviderKey { return desc ? resolveProviderGroupForModel(desc) : "opencode"; @@ -7004,12 +7014,42 @@ export function AgentChatPane({ }, [initialSessionSummary, lockSessionId, projectRoot, refreshAvailableModels, refreshSessions]); useEffect(() => { - const selectableModelIds = modelSelectionConstrained ? effectiveAvailableModelIds : availableModelIds; + const isFreshDraft = !selectedSessionId && selectedEvents.length === 0; + const selectableModelIds = modelSelectionConstrained || isFreshDraft + ? effectiveAvailableModelIds + : availableModelIds; if (loading || !selectableModelIds.length) return; // If the user hasn't picked a model yet, don't auto-select one. if (!modelId) return; if (selectableModelIds.includes(modelId)) return; if (modelSelectionConstrained) return; + if (isFreshDraft) { + const currentModelDesc = resolveScopedModelDescriptor(modelId, modelCatalogScopeKey); + const currentModelIsAcp = currentModelDesc?.family === "qwen" + || currentModelDesc?.family === "moonshot" + || currentModelDesc?.family === "xai" + || currentModelDesc?.family === "github-copilot"; + const hasLiveAcpAlternative = Boolean( + currentModelIsAcp + && currentModelDesc + && effectiveAvailableModelIds.some((candidateId) => { + if (candidateId === modelId) return false; + return resolveScopedModelDescriptor(candidateId, modelCatalogScopeKey)?.family === currentModelDesc.family; + }), + ); + // A known non-ACP model may be intentionally launchable even when the + // passive inventory has not reported it (for example, an installed CLI + // subscription). ACP models are different: once live discovery reports + // a same-provider alternative, a persisted curated row is stale and + // must not survive into a fresh draft. + if (currentModelDesc && !hasLiveAcpAlternative) return; + const preferred = readLastUsedModelId(); + const nextModelId = preferred && selectableModelIds.includes(preferred) + ? preferred + : selectableModelIds[0]!; + if (nextModelId !== modelId) setModelId(nextModelId); + return; + } const modelDesc = resolveScopedModelDescriptor(modelId, modelCatalogScopeKey); // Runtime catalog can surface Cursor/Droid SDK models before ai status catches up. if (isKnownSelectableChatModelId(modelId) || modelDesc) return; @@ -7023,7 +7063,7 @@ export function AgentChatPane({ } else { setModelId(selectableModelIds[0]!); } - }, [loading, availableModelIds, effectiveAvailableModelIds, modelId, modelSelectionConstrained, selectedSessionModelId, modelCatalogScopeKey]); + }, [loading, availableModelIds, effectiveAvailableModelIds, modelId, modelSelectionConstrained, modelCatalogScopeKey, selectedEvents.length, selectedSessionId, selectedSessionModelId]); useEffect(() => { selectedSessionIdRef.current = selectedSessionId; diff --git a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx index e0cf9cd087..7c4fb27dc4 100644 --- a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ArrowClockwise, CheckCircle, CopySimple, Play, Terminal, Warning } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { ProviderSignInModal } from "../settings/providers/ProviderSignInModal"; +import { acpLoginCommand } from "../settings/providers/acpProviders"; export type AgentCliAuthCardInfo = { agent: string; @@ -181,7 +183,12 @@ export function AgentCliAuthCard({ const canRetry = !missing && Boolean(chatSessionId); const [loginStarted, setLoginStarted] = useState(false); + const [signInOpen, setSignInOpen] = useState(false); const [retrying, setRetrying] = useState(false); + // The ACP providers can sign in without leaving the chat: the login is a + // terminal flow, and ADE can host that terminal in a dialog. The other agents + // keep the "open a terminal in Work" affordance they already had. + const embeddedSignInCommand = missing ? null : acpLoginCommand(agentCli.agent); const [resolved, setResolved] = useState(false); const retryResetTimerRef = useRef(null); @@ -306,16 +313,30 @@ export function AgentCliAuthCard({ {agentCli.authCommand} - setLoginStarted(true)} - /> + {embeddedSignInCommand ? ( + + ) : ( + setLoginStarted(true)} + /> + )} @@ -346,6 +367,25 @@ export function AgentCliAuthCard({ + {signInOpen && embeddedSignInCommand ? ( + setSignInOpen(false)} + // Signing in does not resend anything on its own: the retry button + // above is the one place a turn is re-dispatched, and it now reads as + // the obvious next step. + onSignedIn={() => setLoginStarted(true)} + checkSignedIn={async () => { + const status = await window.ade?.ai?.getStatus?.({ force: true }); + const connection = (status?.providerConnections as Record | undefined) + ?.[agentCli.agent]; + return connection?.authAvailable === true; + }} + /> + ) : null} ); } diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 572c383192..493416bde8 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -267,7 +267,10 @@ function writeJsonLs(key: string, value: unknown): void { } } -type ResolverPermissionFamily = Extract; +type ResolverPermissionFamily = Extract< + ModelProviderGroup, + "claude" | "codex" | "opencode" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot" +>; type ResolverPermissionPreferences = Record; const DEFAULT_RESOLVER_PERMISSIONS: ResolverPermissionPreferences = { @@ -277,6 +280,10 @@ const DEFAULT_RESOLVER_PERMISSIONS: ResolverPermissionPreferences = { cursor: "default", droid: "edit", pi: "default", + qwen: "default", + kimi: "default", + grok: "default", + copilot: "default", }; function normalizeResolverPermissionMode(value: unknown): PrAgentPermissionMode | null { @@ -305,6 +312,10 @@ function readPersistedResolverPermissions(): ResolverPermissionPreferences { cursor: normalizeResolverPermissionMode(parsed?.cursor) ?? DEFAULT_RESOLVER_PERMISSIONS.cursor, droid: normalizeResolverPermissionMode(parsed?.droid) ?? DEFAULT_RESOLVER_PERMISSIONS.droid, pi: normalizeResolverPermissionMode(parsed?.pi) ?? DEFAULT_RESOLVER_PERMISSIONS.pi, + qwen: normalizeResolverPermissionMode(parsed?.qwen) ?? DEFAULT_RESOLVER_PERMISSIONS.qwen, + kimi: normalizeResolverPermissionMode(parsed?.kimi) ?? DEFAULT_RESOLVER_PERMISSIONS.kimi, + grok: normalizeResolverPermissionMode(parsed?.grok) ?? DEFAULT_RESOLVER_PERMISSIONS.grok, + copilot: normalizeResolverPermissionMode(parsed?.copilot) ?? DEFAULT_RESOLVER_PERMISSIONS.copilot, }; } catch { return DEFAULT_RESOLVER_PERMISSIONS; diff --git a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx index eb685e5f52..88b655db40 100644 --- a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.test.tsx @@ -30,6 +30,7 @@ vi.mock("@lobehub/icons", () => { OpenAI: brand(), OpenCode: brand(), OpenRouter: brand(), + Qwen: brand(), XAI: brand(), }; }); diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx index 24bd5af760..41d2e42e73 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx @@ -36,6 +36,7 @@ vi.mock("@lobehub/icons", () => { OpenAI: brand(), OpenCode: brand(), OpenRouter: brand(), + Qwen: brand(), XAI: brand(), }; }); @@ -241,10 +242,15 @@ function buildPiInstallation(): NonNullable }; } -function renderProvidersSection() { +/** + * Settings → Agents & Models is a grid of providers plus one page per provider. + * Passing an id renders that provider's page directly, which is what a + * `?provider=` deeplink does; passing nothing renders the grid. + */ +function renderProvidersSection(providerId: string | null = null) { return render( - + , ); } @@ -355,7 +361,7 @@ describe("ProvidersSection", () => { }); it("refreshes provider status after an auth-related chat failure", async () => { - renderProvidersSection(); + renderProvidersSection("claude"); const ade = window.ade as any; await waitFor(() => { @@ -364,7 +370,7 @@ describe("ProvidersSection", () => { }); expect(ade.ai.getStatus).toHaveBeenNthCalledWith(1, { force: false, - refreshOpenCodeInventory: true, + refreshOpenCodeInventory: false, }); expect((await screen.findAllByText("/Users/arul/ADE/apps/desktop/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude")).length).toBeGreaterThan(0); @@ -385,25 +391,25 @@ describe("ProvidersSection", () => { expect(ade.ai.listApiKeys).toHaveBeenCalledTimes(2); }, { timeout: 2_000 }); - expect(await screen.findByText("Sign-In Required")).toBeTruthy(); + expect(await screen.findByText("Sign in required")).toBeTruthy(); expect(screen.getByText("Sign in to use Claude")).toBeTruthy(); expect(screen.getAllByText("/Users/arul/ADE/apps/desktop/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude").length).toBeGreaterThan(0); }); - it("shows Ready while the bundled Claude runtime is authenticated", async () => { - renderProvidersSection(); + it("shows Connected while the bundled Claude runtime is authenticated", async () => { + renderProvidersSection("claude"); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); expect(window.ade.ai.listApiKeys).toHaveBeenCalledTimes(1); }); - expect((await screen.findAllByText("Ready")).length).toBeGreaterThan(0); + expect((await screen.findAllByText("Connected")).length).toBeGreaterThan(0); expect(screen.getByText("Uses your claude login — Claude Pro/Max subscription or ANTHROPIC_API_KEY.")).toBeTruthy(); expect(screen.getAllByText("/Users/arul/ADE/apps/desktop/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude").length).toBeGreaterThan(0); }); - it("shows Binary Missing when the Claude SDK native binary is unavailable", async () => { + it("shows Not installed when the Claude SDK native binary is unavailable", async () => { const getStatusMock = window.ade.ai.getStatus as ReturnType; getStatusMock.mockReset(); getStatusMock.mockResolvedValue(buildStatus(false, [], { @@ -411,19 +417,19 @@ describe("ProvidersSection", () => { claudeAuthReady: false, })); - renderProvidersSection(); + renderProvidersSection("claude"); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); expect(window.ade.ai.listApiKeys).toHaveBeenCalledTimes(1); }); - expect(await screen.findByText("Binary Missing")).toBeTruthy(); - expect(screen.getByText("Claude unavailable (binary missing; should not happen with bundled install; run /doctor).")).toBeTruthy(); + expect(await screen.findByText("Not installed")).toBeTruthy(); + expect(screen.getAllByText("Claude unavailable (binary missing; should not happen with bundled install; run /doctor).").length).toBeGreaterThan(0); }); it("renders local runtime details and loaded local models", async () => { - renderProvidersSection(); + renderProvidersSection("opencode"); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -449,7 +455,7 @@ describe("ProvidersSection", () => { }), ); - const view = renderProvidersSection(); + const view = renderProvidersSection("opencode"); const current = within(view.container); await waitFor(() => { @@ -472,7 +478,7 @@ describe("ProvidersSection", () => { .mockResolvedValueOnce([]) .mockResolvedValue(["cursor"]); - renderProvidersSection(); + renderProvidersSection("cursor"); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -515,7 +521,7 @@ describe("ProvidersSection", () => { verifiedAt: "2026-03-17T19:00:00.000Z", }); - renderProvidersSection(); + renderProvidersSection("cursor"); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -536,7 +542,9 @@ describe("ProvidersSection", () => { expect(alert.textContent).toContain("Cannot find package '@cursor/sdk'"); expect(screen.queryByText("Cursor verification failed.")).toBeNull(); expect(screen.queryByText("Invalid key")).toBeNull(); - expect(screen.getByText("Verification failed")).toBeTruthy(); + // The grid has exactly six status words; a failed verify is one of them, + // not a seventh phrase invented by the Cursor descriptor. + expect(screen.getByText("Needs attention")).toBeTruthy(); await act(async () => { screen.getByLabelText("Dismiss error message").click(); @@ -549,7 +557,7 @@ describe("ProvidersSection", () => { getStatusMock.mockReset(); getStatusMock.mockResolvedValue(buildStatus(true, [])); - renderProvidersSection(); + renderProvidersSection("cursor"); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -561,6 +569,56 @@ describe("ProvidersSection", () => { expect(screen.queryByLabelText("Sign out of Cursor")).toBeNull(); }); + it("does not say Sign in required on the Cursor tile when Cursor OAuth is already logged in", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + const status = buildStatus(true, []); + const connections = status.providerConnections; + if (!connections) { + throw new Error("expected providerConnections on Cursor status"); + } + getStatusMock.mockResolvedValue({ + ...status, + availableProviders: { ...status.availableProviders, cursor: false }, + providerConnections: { + ...connections, + cursor: { + provider: "cursor", + authAvailable: true, + runtimeDetected: true, + runtimeAvailable: false, + usageAvailable: false, + path: "@cursor/sdk", + blocker: "Verify the Cursor API key to enable Cursor chat.", + lastCheckedAt: "2026-03-17T19:00:00.000Z", + accountEmail: "ada@cursor.com", + sources: [ + { + kind: "local-credentials", + detected: true, + source: "cursor-oauth", + }, + ], + }, + }, + }); + const cursorAuthStatus = window.ade.ai.cursorAuthStatus as ReturnType; + cursorAuthStatus.mockResolvedValue({ + sdkStatus: "logged-in", + email: "ada@cursor.com", + adeKeyPresent: false, + credentialSource: "cursor-oauth", + loginInProgress: false, + }); + + renderProvidersSection(); + + const tile = await screen.findByLabelText("Open Cursor settings"); + expect(await within(tile).findByText("Needs attention")).toBeTruthy(); + expect(within(tile).queryByText("Sign in required")).toBeNull(); + expect(within(tile).getByText("Verify the Cursor API key to enable Cursor chat.")).toBeTruthy(); + }); + it("signs in with Cursor, shows the login URL while pending, then signs out", async () => { const getStatusMock = window.ade.ai.getStatus as ReturnType; getStatusMock.mockReset(); @@ -606,7 +664,7 @@ describe("ProvidersSection", () => { const cursorAuthStatus = window.ade.ai.cursorAuthStatus as ReturnType; const listApiKeysMock = window.ade.ai.listApiKeys as ReturnType; - renderProvidersSection(); + renderProvidersSection("cursor"); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -665,7 +723,7 @@ describe("ProvidersSection", () => { listApiKeysMock.mockReset(); listApiKeysMock.mockResolvedValue(["cursor"]); - renderProvidersSection(); + renderProvidersSection("cursor"); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -685,16 +743,77 @@ describe("ProvidersSection", () => { }); }); - it("renders the Coding Agents section and OpenCode popular provider cards", async () => { + it("renders one labelled tile per provider on the grid", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [])); + + renderProvidersSection(); + + for (const label of ["Claude Code", "Codex CLI", "Cursor", "Droid", "Pi", "OpenCode"]) { + expect(await screen.findByLabelText(`Open ${label} settings`), label).toBeTruthy(); + } + // Status is a word, not a colour: every tile says which of the six it is. + expect(screen.getAllByText(/^(Connected|Sign in required|Needs attention|Not installed|Checking…|Disabled)$/).length) + .toBeGreaterThan(0); + // The catalogs are behind their provider, not spilled onto the grid. + expect(screen.queryByLabelText("Search all OpenCode providers")).toBeNull(); + }); + + // "GitHub Copilot" is the longest name on the grid, and it used to render as + // "GitHub Co…" because the logo, the name, the Preview chip, and an uppercase + // letterspaced status chip all shared one 280px row. The fix has to hold at + // the markup level: whatever else the tile clips, it is not the name. + it("does not clip a long provider name on its tile", async () => { const getStatusMock = window.ade.ai.getStatus as ReturnType; getStatusMock.mockReset(); getStatusMock.mockResolvedValue(buildStatus(true, [])); renderProvidersSection(); - expect(await screen.findByText("Coding Agents")).toBeTruthy(); - expect(screen.getByText("OpenCode — Universal Model Access")).toBeTruthy(); - expect(screen.getByText(/^All providers · \d+$/)).toBeTruthy(); + const tile = await screen.findByLabelText("Open GitHub Copilot settings"); + const name = within(tile).getByTestId("provider-tile-name-copilot"); + expect(name.textContent).toBe("GitHub Copilot"); + expect(name.style.textOverflow).toBe(""); + expect(name.style.whiteSpace).toBe(""); + }); + + // A status probe that has not answered is not the same claim as "this is not + // installed", and the grid has to say so while the first probe is out. + it("says Checking on every tile until the first status lands", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockImplementation(() => new Promise(() => undefined)); + + renderProvidersSection(); + + expect((await screen.findAllByText("Checking…")).length).toBeGreaterThan(0); + expect(screen.queryByText("Not installed")).toBeNull(); + }); + + it("does not leave Qwen on Checking after the first status probe fails", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockRejectedValue( + new Error("Remote ADE service timed out waiting for method ade/actions/call (30000ms)."), + ); + + renderProvidersSection(); + + const tile = await screen.findByLabelText("Open Qwen Code settings"); + expect(await within(tile).findByText("Needs attention")).toBeTruthy(); + expect(within(tile).queryByText("Checking…")).toBeNull(); + expect(screen.queryByText("Not installed")).toBeNull(); + }); + + it("renders the OpenCode catalog on the OpenCode page", async () => { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true, [])); + + renderProvidersSection("opencode"); + + expect(await screen.findByText(/^All providers · \d+$/)).toBeTruthy(); expect(screen.getByLabelText("Search all OpenCode providers")).toBeTruthy(); // Popular cards include Moonshot and Kimi. expect(screen.getByText("Moonshot AI")).toBeTruthy(); @@ -723,7 +842,7 @@ describe("ProvidersSection", () => { listApiKeysMock.mockReset(); listApiKeysMock.mockResolvedValue([]); - renderProvidersSection(); + renderProvidersSection("pi"); expect(await screen.findByText("Pi")).toBeTruthy(); expect(screen.getByText(/Uses Pi’s installed SDK package/)).toBeTruthy(); @@ -755,7 +874,7 @@ describe("ProvidersSection", () => { }), ); - renderProvidersSection(); + renderProvidersSection("pi"); const signIn = await openPiProviderSignIn("xAI", "Sign in with SuperGrok — xAI"); expect(screen.getByRole("button", { name: "Use an API key — xAI" })).toBeTruthy(); @@ -815,7 +934,7 @@ describe("ProvidersSection", () => { error: "That prompt has already been answered.", }); - renderProvidersSection(); + renderProvidersSection("pi"); const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); await act(async () => { @@ -850,7 +969,7 @@ describe("ProvidersSection", () => { const startMock = window.ade.ai.piLoginStart as ReturnType; startMock.mockResolvedValue({ ok: false, error: "Device code expired." }); - renderProvidersSection(); + renderProvidersSection("pi"); const signIn = await openPiProviderSignIn("xAI", "Sign in with SuperGrok — xAI"); await act(async () => { @@ -879,7 +998,7 @@ describe("ProvidersSection", () => { }), ); - renderProvidersSection(); + renderProvidersSection("pi"); const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); await act(async () => { @@ -906,7 +1025,7 @@ describe("ProvidersSection", () => { { id: "openai-codex", name: "OpenAI Codex", authTypes: ["oauth"], configured: true }, ]); - renderProvidersSection(); + renderProvidersSection("pi"); expect((await screen.findAllByText("OpenAI Codex")).length).toBe(1); expect(screen.getByText(/7 models/)).toBeTruthy(); @@ -926,7 +1045,7 @@ describe("ProvidersSection", () => { () => new Promise<{ ok: boolean; error?: string }>(() => undefined), ); - const view = renderProvidersSection(); + const view = renderProvidersSection("pi"); const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); await act(async () => { signIn.click(); @@ -948,7 +1067,7 @@ describe("ProvidersSection", () => { { id: "xai", name: "xAI", authTypes: ["oauth"], configured: false }, ]); - renderProvidersSection(); + renderProvidersSection("pi"); await screen.findByRole("button", { name: "Connect xAI in Pi" }); getStatusMock.mockClear(); @@ -976,7 +1095,7 @@ describe("ProvidersSection", () => { }), ); - renderProvidersSection(); + renderProvidersSection("pi"); const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); await act(async () => { signIn.click(); @@ -1008,7 +1127,7 @@ describe("ProvidersSection", () => { () => new Promise<{ ok: boolean; error?: string }>(() => undefined), ); - renderProvidersSection(); + renderProvidersSection("pi"); const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); await act(async () => { signIn.click(); @@ -1054,7 +1173,7 @@ describe("ProvidersSection", () => { () => new Promise<{ ok: boolean; error?: string }>(() => undefined), ); - renderProvidersSection(); + renderProvidersSection("pi"); const signIn = await openPiProviderSignIn("xAI", "Sign in — xAI"); await act(async () => { signIn.click(); @@ -1099,7 +1218,7 @@ describe("ProvidersSection", () => { })); (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([]); - renderProvidersSection(); + renderProvidersSection("pi"); const localServers = await screen.findByRole("group", { name: "Pi local model servers" }); expect(within(localServers).getByText("Local Model Servers")).toBeTruthy(); @@ -1138,7 +1257,7 @@ describe("ProvidersSection", () => { })); (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([]); - renderProvidersSection(); + renderProvidersSection("pi"); const localServers = await screen.findByRole("group", { name: "Pi local model servers" }); expect(within(localServers).getByText("llama.cpp")).toBeTruthy(); @@ -1174,7 +1293,7 @@ describe("ProvidersSection", () => { })); (window.ade.ai.piLoginProviders as ReturnType).mockResolvedValue([]); - renderProvidersSection(); + renderProvidersSection("pi"); const localServers = await screen.findByRole("group", { name: "Pi local model servers" }); getStatusMock.mockClear(); @@ -1194,7 +1313,7 @@ describe("ProvidersSection", () => { { id: "groq", name: "Groq", authTypes: ["api_key"], configured: false }, ]); - renderProvidersSection(); + renderProvidersSection("pi"); const search = await screen.findByLabelText("Search all Pi providers"); expect(screen.getByRole("button", { name: "Connect Groq in Pi" })).toBeTruthy(); @@ -1216,9 +1335,9 @@ describe("ProvidersSection", () => { }, })); - renderProvidersSection(); + renderProvidersSection("pi"); - expect(await screen.findAllByText("Pi is installed, but ADE cannot load its package here.")).toHaveLength(1); + expect((await screen.findAllByText("Pi is installed, but ADE cannot load its package here.")).length).toBe(1); // ADE used to offer to open Pi and type `/login` into its TUI after a fixed // delay. That raced Pi's startup and submitted empty lines, so the branch // states the instruction rather than automating it. @@ -1233,7 +1352,7 @@ describe("ProvidersSection", () => { getStatusMock.mockReset(); getStatusMock.mockResolvedValue(buildStatus(true, [], { opencodeBinaryInstalled: false })); - renderProvidersSection(); + renderProvidersSection("opencode"); expect(await screen.findByText("npm i -g opencode-ai")).toBeTruthy(); expect(screen.getByText("brew install anomalyco/tap/opencode")).toBeTruthy(); @@ -1260,7 +1379,7 @@ describe("ProvidersSection", () => { }, }); - renderProvidersSection(); + renderProvidersSection("opencode"); expect(await screen.findByLabelText("Connect OpenAI")).toBeTruthy(); expect(screen.getByText(/Updating provider catalog/i)).toBeTruthy(); @@ -1293,7 +1412,7 @@ describe("ProvidersSection", () => { }, }); - renderProvidersSection(); + renderProvidersSection("opencode"); expect(await screen.findByLabelText("Connect OpenAI")).toBeTruthy(); expect(screen.queryByLabelText("Connect Cursor")).toBeNull(); @@ -1306,7 +1425,7 @@ describe("ProvidersSection", () => { getStatusMock.mockReset(); getStatusMock.mockRejectedValue(new Error("status probe unavailable")); - renderProvidersSection(); + renderProvidersSection("opencode"); expect(await screen.findByText("Could not load OpenCode status.")).toBeTruthy(); expect(screen.getByRole("button", { name: "Re-check OpenCode" })).toBeTruthy(); @@ -1324,7 +1443,7 @@ describe("ProvidersSection", () => { authMethodsMock.mockReset(); authMethodsMock.mockRejectedValue(new Error("catalog unavailable")); - renderProvidersSection(); + renderProvidersSection("opencode"); await waitFor(() => { expect(authMethodsMock).toHaveBeenCalledTimes(1); @@ -1343,7 +1462,7 @@ describe("ProvidersSection", () => { const setProviderKeyMock = window.ade.ai.setOpencodeProviderKey as ReturnType; setProviderKeyMock.mockResolvedValue({ ok: false, error: "OpenCode rejected this key." }); - renderProvidersSection(); + renderProvidersSection("opencode"); const openAiCard = await screen.findByLabelText("Connect OpenAI"); await act(async () => { @@ -1381,7 +1500,7 @@ describe("ProvidersSection", () => { authMethodsMock.mockReset(); authMethodsMock.mockRejectedValue(new Error("catalog unavailable")); - renderProvidersSection(); + renderProvidersSection("opencode"); // Wait until stored keys hydrate so the card reflects key ownership. await waitFor(() => { @@ -1421,7 +1540,7 @@ describe("ProvidersSection", () => { }, }); - renderProvidersSection(); + renderProvidersSection("opencode"); const openAiCard = await screen.findByLabelText("Connect OpenAI"); await act(async () => { @@ -1459,6 +1578,123 @@ describe("ProvidersSection", () => { expect(screen.queryByRole("dialog", { name: "Connect OpenAI" })).toBeNull(); }); }); + // The four ACP providers plug into the same descriptor system as the six. + // These pin the parts a copy-paste port would get wrong. + describe("ACP providers", () => { + it("gives each ACP provider a tile on the grid", async () => { + const view = renderProvidersSection(); + const current = within(view.container); + await waitFor(() => { + expect(window.ade.ai.getStatus).toHaveBeenCalled(); + }); + for (const label of ["Qwen Code", "Kimi", "Grok", "GitHub Copilot"]) { + expect(current.getByLabelText(`Open ${label} settings`), label).toBeTruthy(); + } + }); + + it("says Kimi hides the usage meter, in plain words, on its page", async () => { + const view = renderProvidersSection("kimi"); + const current = within(view.container); + expect( + (await current.findAllByText("Kimi does not report token usage; the usage meter stays hidden.")).length, + ).toBeGreaterThan(0); + expect(current.getByText(/--region global/)).toBeTruthy(); + expect(current.getByText(/does not write that file/)).toBeTruthy(); + }); + + it("says ADE reuses the Qwen CLI the user already set up", async () => { + const view = renderProvidersSection("qwen"); + const current = within(view.container); + expect(await current.findByText(/does not write ~\/.qwen/)).toBeTruthy(); + expect(current.getAllByText(/OPENAI_BASE_URL/).length).toBeGreaterThan(0); + }); + + // The chip is a claim about the models, so it must come from the registry's + // `previewTier`, not from a hand-maintained list of provider names. + it("marks only the preview-tier providers with a Preview chip", async () => { + for (const [provider, expected] of [["grok", true], ["copilot", true], ["qwen", false], ["kimi", false]] as const) { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true)); + const view = renderProvidersSection(provider); + const current = within(view.container); + await waitFor(() => { + expect(getStatusMock).toHaveBeenCalled(); + }); + expect(current.queryAllByText("Preview").length > 0, provider).toBe(expected); + cleanup(); + } + }); + + it("runs the vendor doctor only where one exists", async () => { + const runDiagnostics = vi.fn().mockResolvedValue({ + provider: "grok", + binaryPath: "/opt/homebrew/bin/grok", + binarySource: "path", + configHome: "/Users/ada/.grok", + version: "1.0.14", + versionError: null, + lastProbe: null, + doctor: { command: "grok doctor", exitCode: 0, output: "everything is fine" }, + checkedAt: "2026-08-31T00:00:00.000Z", + }); + (window.ade as any).ai.acpProviderDiagnostics = runDiagnostics; + + const view = renderProvidersSection("grok"); + const current = within(view.container); + const button = await current.findByRole("button", { name: "Run grok doctor" }); + await act(async () => { + fireEvent.click(button); + }); + await waitFor(() => { + expect(current.getByText("everything is fine")).toBeTruthy(); + }); + expect(runDiagnostics).toHaveBeenCalledWith({ provider: "grok", runDoctor: true }); + + cleanup(); + // Qwen ships no `doctor`, so offering the button would run the word as a + // prompt. + const qwenView = renderProvidersSection("qwen"); + await waitFor(() => { + expect(window.ade.ai.getStatus).toHaveBeenCalled(); + }); + expect(within(qwenView.container).queryByRole("button", { name: /doctor/ })).toBeNull(); + }); + }); + + describe("provider disable toggle", () => { + it("writes the whole disabled list and keeps the page reachable", async () => { + const view = renderProvidersSection("grok"); + const current = within(view.container); + const disable = await current.findByRole("button", { name: "Disable Grok" }); + await act(async () => { + fireEvent.click(disable); + }); + expect(window.ade.ai.updateConfig).toHaveBeenCalledWith({ disabledProviders: ["grok"] }); + }); + + it("shows Disabled on the tile and offers the way back on", async () => { + (window.ade as any).projectConfig.get = vi.fn().mockResolvedValue({ + effective: { ai: { disabledProviders: ["grok"] } }, + }); + + const grid = renderProvidersSection(); + await waitFor(() => { + expect(window.ade.ai.getStatus).toHaveBeenCalled(); + }); + const tile = within(grid.container).getByLabelText("Open Grok settings"); + await waitFor(() => { + expect(within(tile).getByText("Disabled")).toBeTruthy(); + }); + + cleanup(); + const page = renderProvidersSection("grok"); + // The switch has to be findable from the page it turned off, or it is a + // one-way door. + expect(await within(page.container).findByRole("button", { name: "Enable Grok" })).toBeTruthy(); + }); + }); + // @cursor/sdk has no win32-arm64 build, so the Cursor card must be absent on // Windows on ARM rather than present and permanently unconnectable. // See apps/desktop/src/shared/providerPlatformSupport.ts. @@ -1477,13 +1713,11 @@ describe("ProvidersSection", () => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); }); - expect(current.queryByText("Sign in with Cursor or use a Cursor API key.")).toBeNull(); - expect(current.queryByLabelText("Add Cursor API key")).toBeNull(); - expect(current.queryByLabelText("Verify Cursor API key")).toBeNull(); + expect(current.queryByLabelText("Open Cursor settings")).toBeNull(); // The other providers are untouched. - expect((await current.findAllByText("Claude Code")).length).toBeGreaterThan(0); - expect(current.getAllByText("Codex CLI").length).toBeGreaterThan(0); - expect(current.getAllByText("Droid").length).toBeGreaterThan(0); + expect(await current.findByLabelText("Open Claude Code settings")).toBeTruthy(); + expect(current.getByLabelText("Open Codex CLI settings")).toBeTruthy(); + expect(current.getByLabelText("Open Droid settings")).toBeTruthy(); }); it("keeps the Cursor card on Windows x64 and on macOS", async () => { @@ -1492,7 +1726,7 @@ describe("ProvidersSection", () => { getStatusMock.mockReset(); getStatusMock.mockResolvedValue(buildStatus(true)); setRuntimeTarget(platform, arch); - const view = renderProvidersSection(); + const view = renderProvidersSection("cursor"); const current = within(view.container); expect( diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 5f397d60f0..a5a56bfb24 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -1,158 +1,66 @@ +/** + * Settings → Agents & Models. + * + * A grid of providers, and one page per provider. This file owns the data — + * one status probe, one set of handlers — and nothing about how any individual + * provider looks: that lives in `providers/descriptors.tsx`. Before the split, + * each provider was its own hand-written block here and the file had grown past + * 1800 lines with six different vocabularies for "connected". + */ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { + AgentChatPermissionMode, AiConfig, AiApiKeyVerificationResult, - AiClaudeAvailability, - AiProviderConnectionStatus, AiSettingsStatus, ProjectConfigSnapshot, CursorSdkAuthEvent, CursorSdkAuthStatus, } from "../../../shared/types"; import type { + AcpProviderDiagnostics, AiCustomProviderConfig, + AiProviderPermissions, OpenCodeProviderAuthMethods, } from "../../../shared/types/config"; +import { toggleDisabledProvider } from "../../../shared/providerEnablement"; import { - getLocalModelIdTail, getLocalProviderDefaultEndpoint, - getModelById, LOCAL_PROVIDER_LABELS, - parseLocalProviderFromModelId, type LocalProviderFamily, } from "../../../shared/modelRegistry"; -import { - ArrowsClockwise, - CheckCircle, - Copy, - Cpu, - Info, - WarningCircle, - X, - XCircle, -} from "@phosphor-icons/react"; -import { ClaudeLogo, CodexLogo, CursorAgentLogo, OpenCodeLogo } from "../terminals/ToolLogos"; -import { PiLogo, ProviderLogo } from "../shared/ProviderLogos"; -import { - COLORS, - MONO_FONT, - SANS_FONT, - LABEL_STYLE, - SECTION_LABEL_STYLE, - outlineButton, - primaryButton, -} from "../lanes/laneDesignTokens"; -import { cursorProviderAvailable, rendererPlatformAttribute } from "../../lib/platform"; -import { openExternalUrl } from "../../lib/openExternal"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { COLORS, LABEL_STYLE } from "../lanes/laneDesignTokens"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { shouldRefreshAiStatusForChatEvent } from "../../lib/aiProviderStatus"; import { showToast } from "../app/toast/toastStore"; -import { ClaudeLoginPromptButton, revealTerminalSessionInWork } from "../work/ClaudeLoginPromptButton"; +import { revealTerminalSessionInWork } from "../work/ClaudeLoginPromptButton"; import { OpenCodeProviderDetailModal, type ApiKeySource, type OpenCodeProviderDetail, } from "./OpenCodeProviderDetailModal"; -import { - CollapsibleProviderCard, - ConnectedTag, - ProviderGrid, - ProviderSearchField, - ProviderTile, - ProviderTileBadge, - panel, -} from "./providerSectionPrimitives"; -import { PiProvidersPanel, buildPiMessage, getPiTone } from "./PiProvidersPanel"; - -type CliName = "claude" | "codex" | "cursor" | "droid"; +import { ProviderGrid } from "./providerSectionPrimitives"; +import { availableProviderDescriptors, providerDescriptor } from "./providers/descriptors"; +import { ProviderTileCard } from "./providers/ProviderTileCard"; +import { ProviderDetailPage } from "./providers/ProviderDetailPage"; +import { ProviderSignInModal } from "./providers/ProviderSignInModal"; +import { acpLoginCommand, acpProviderLabel } from "./providers/acpProviders"; +import { AlertBanner, prettifyProviderId } from "./providers/providerUi"; +import type { + AcpSettingsProviderId, + CustomProviderDraft, + LocalProviderDraft, + LocalRuntimeRow, + ProvidersViewContext, + SettingsProviderId, +} from "./providers/types"; + +export { openCodeInstallCommands } from "./providers/cliTools"; const KIMI_PROVIDER_ID = "kimi-for-coding"; const OPENCODE_CATALOG_EXCLUDED_IDS = new Set(["cursor", "ollama", "lmstudio"]); -/** - * OpenCode's own documented install methods, per platform. Windows has neither - * Homebrew nor a POSIX shell to pipe the install script into, so it gets the - * package managers OpenCode actually documents for Windows (npm, Scoop, - * Chocolatey) instead of commands that cannot run there. - */ -export function openCodeInstallCommands( - platform: ReturnType = rendererPlatformAttribute(), -): string[] { - if (platform === "win32") { - return [ - "npm i -g opencode-ai", - "scoop install opencode", - "choco install opencode", - ]; - } - return [ - "brew install anomalyco/tap/opencode", - "npm i -g opencode-ai", - "curl -fsSL https://opencode.ai/install | bash", - ]; -} - -const CUSTOM_PROVIDER_NPM_OPTIONS = [ - "@ai-sdk/openai-compatible", - "@ai-sdk/openai", - "@ai-sdk/anthropic", -]; - -// Factory ships a native Windows build of `droid` with its own installer and -// its own way of setting an environment variable — a POSIX `export` line and a -// bare docs link leave a Windows user with nothing to run. -// https://docs.factory.ai/cli/getting-started/quickstart -const DROID_INSTALL_HINT = rendererPlatformAttribute() === "win32" - ? "irm https://app.factory.ai/cli/windows | iex — installs droid.exe into %USERPROFILE%\\bin and puts it on PATH" - : "curl -fsSL https://app.factory.ai/cli | sh — ensure `droid` is on PATH"; -const DROID_LOGIN_CMD = rendererPlatformAttribute() === "win32" - ? "setx FACTORY_API_KEY … (or sign in via `droid` interactive login)" - : "export FACTORY_API_KEY=… (or sign in via `droid` interactive login)"; - -const CLI_TOOLS: Array<{ - cli: CliName; - label: string; - authStory: string; - loginCmd: string; - installHint: string; - /** Used instead of installHint on Windows, where the vendor ships a different installer. */ - windowsInstallHint?: string; -}> = [ - { - cli: "claude", - label: "Claude Code", - authStory: "Uses your claude login — Claude Pro/Max subscription or ANTHROPIC_API_KEY.", - loginCmd: "claude auth login or set ANTHROPIC_API_KEY", - installHint: "npm install -g @anthropic-ai/claude-code", - // Anthropic's documented Windows installs: the PowerShell native installer - // (drops claude.exe in %USERPROFILE%\.localin) or WinGet. - windowsInstallHint: "irm https://claude.ai/install.ps1 | iex (PowerShell), or winget install Anthropic.ClaudeCode", - }, - { - cli: "codex", - label: "Codex CLI", - authStory: "Uses your ChatGPT sign-in — Plus/Pro subscription or OPENAI_API_KEY.", - loginCmd: "codex login", - installHint: "npm install -g @openai/codex", - }, - { - cli: "cursor", - label: "Cursor", - authStory: "Sign in with Cursor or use a Cursor API key.", - loginCmd: "Sign in with Cursor or add a Cursor API key", - installHint: "Get a Cursor API key from https://cursor.com/dashboard/api", - }, - { - cli: "droid", - label: "Droid", - authStory: "Uses your Factory login or FACTORY_API_KEY.", - loginCmd: DROID_LOGIN_CMD, - installHint: DROID_INSTALL_HINT, - }, -]; - const LOCAL_PROVIDER_SPECS: Array<{ provider: LocalProviderFamily; label: string; @@ -180,27 +88,11 @@ const API_KEY_PROVIDERS: Array<{ { provider: "moonshotai", label: "Moonshot AI", envVar: "MOONSHOT_API_KEY", placeholder: "sk-..." }, ]; -type LocalProviderDraft = { - enabled: boolean; - endpoint: string; - autoDetect: boolean; - preferredModelId: string; -}; - -type CustomProviderDraft = { - id: string; - name: string; - baseUrl: string; - npm: string; - slugs: string; - apiKey: string; -}; - const EMPTY_CUSTOM_PROVIDER: CustomProviderDraft = { id: "", name: "", baseUrl: "", - npm: CUSTOM_PROVIDER_NPM_OPTIONS[0], + npm: "@ai-sdk/openai-compatible", slugs: "", apiKey: "", }; @@ -212,232 +104,6 @@ const groupLabelStyle: React.CSSProperties = { color: COLORS.textSecondary, }; -/** Squared bordered surface — the shared "ledger" panel used across this section. */ -function prettifyProviderId(id: string): string { - return id - .split(/[-_/]/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function AlertBanner({ - tone, - message, - onDismiss, -}: { - tone: "success" | "error" | "warning"; - message: string; - onDismiss: () => void; -}) { - const color = tone === "success" ? COLORS.success : tone === "warning" ? COLORS.warning : COLORS.danger; - const token = tone === "success" ? "success" : tone === "warning" ? "warning" : "error"; - return ( -
- {message} - -
- ); -} - -const SOURCE_BADGE_MAP: Record = { - store: { color: COLORS.success, label: "Local Store" }, - env: { color: COLORS.info, label: "Environment" }, - config: { color: COLORS.warning, label: "Project Config" }, -}; - -function SourceBadge({ source }: { source: ApiKeySource }) { - const { color, label } = SOURCE_BADGE_MAP[source]; - return ( - - {label} - - ); -} - - -function OpenCodeProviderCard({ - provider, - onOpen, -}: { - provider: OpenCodeProviderDetail; - onOpen: () => void; -}) { - const badge = provider.connected - ? "Connected" - : provider.hasKey - ? "Key" - : provider.methods.some((m) => m.type === "oauth") - ? "OAuth" - : "Add"; - return ( - : {badge}} - onOpen={onOpen} - footer={typeof provider.modelCount === "number" ? ( -
- {provider.modelCount} model{provider.modelCount === 1 ? "" : "s"} -
- ) : undefined} - /> - ); -} - -function CopyableCommand({ command }: { command: string }) { - const { copy, copied } = useCopyToClipboard(); - return ( - - ); -} - -function getStatusTone(connection: AiProviderConnectionStatus | null | undefined): { color: string; label: string } { - if (connection?.runtimeAvailable) return { color: COLORS.success, label: "Connected" }; - if (connection?.runtimeDetected || connection?.authAvailable) return { color: COLORS.warning, label: "Sign-In Required" }; - return { color: COLORS.textDim, label: "Not Detected" }; -} - -function getClaudeAvailabilityTone(availability: AiClaudeAvailability | null | undefined): { color: string; label: string } { - if (availability?.binary.present && availability.auth.ready) return { color: COLORS.success, label: "Ready" }; - if (availability?.binary.present) return { color: COLORS.warning, label: "Sign-In Required" }; - return { color: COLORS.textDim, label: "Binary Missing" }; -} - -function buildClaudeAvailabilityMessage(availability: AiClaudeAvailability | null | undefined): string { - if (!availability?.binary.present) { - return "Claude unavailable (binary missing; should not happen with bundled install; run /doctor)."; - } - if (!availability.auth.ready) { - return availability.auth.detail || "Sign in to use Claude"; - } - return "Ready"; -} - -function describeCredentialSource(connection: AiProviderConnectionStatus | null | undefined): string | null { - const localSource = connection?.sources.find((entry) => entry.kind === "local-credentials" && entry.detected); - if (!localSource?.source) return null; - if (localSource.source === "macos-keychain") return "Local credentials found in macOS Keychain."; - if (localSource.source === "claude-credentials-file") return "Local credentials found in ~/.claude/.credentials.json."; - if (localSource.source === "codex-auth-file") return "Local credentials found in ~/.codex/auth.json."; - if (localSource.source === "cursor-env") return "Detected via CURSOR_API_KEY environment variable."; - if (localSource.source === "cursor-api-key-store") return "Cursor API key is stored in ADE encrypted storage."; - if (localSource.source === "cursor-oauth") { - const email = connection?.accountEmail?.trim(); - return email ? `Signed in as ${email}.` : "Signed in with Cursor."; - } - if (localSource.source === "factory-env") return "Detected via FACTORY_API_KEY environment variable."; - if (localSource.source === "pi-auth-file") return "Detected via ~/.pi/agent/auth.json."; - if (localSource.source === "pi-models-file") return "Detected via ~/.pi/agent/models.json."; - return null; -} - -const isWindowsRenderer = rendererPlatformAttribute() === "win32"; - -function installHintFor(tool: (typeof CLI_TOOLS)[number]): string { - return (isWindowsRenderer && tool.windowsInstallHint) || tool.installHint; -} - -function buildCliMessage(tool: (typeof CLI_TOOLS)[number], connection: AiProviderConnectionStatus | null | undefined): string { - if (connection?.runtimeAvailable) { - return "Connection verified."; - } - if (connection?.blocker) { - return connection.blocker; - } - if (connection?.runtimeDetected && !connection.authAvailable) { - return `CLI detected but not signed in. Run: ${tool.loginCmd}`; - } - if (connection?.authAvailable && !connection.runtimeDetected) { - return `Local credentials exist but CLI not found in PATH. Install: ${installHintFor(tool)}`; - } - const pathAdvice = isWindowsRenderer - ? "If already installed, add its folder to your Windows PATH (System Properties -> Environment Variables), reopen ADE, and use Refresh." - : "If already installed, ensure it is on your shell PATH and use Refresh."; - return `CLI not found in PATH. Install: ${installHintFor(tool)}. ${pathAdvice}`; -} - -function formatLocalModelLabel(modelId: string): string { - const descriptor = getModelById(modelId); - if (descriptor) return descriptor.displayName; - const provider = parseLocalProviderFromModelId(modelId); - if (provider) { - const tail = getLocalModelIdTail(modelId, provider); - const brand = LOCAL_PROVIDER_LABELS[provider]; - return tail.length ? `${tail} (${brand})` : String(modelId ?? "").trim(); - } - return String(modelId ?? "").trim(); -} - function buildLocalProviderDrafts( snapshot: ProjectConfigSnapshot | null | undefined, status: AiSettingsStatus | null | undefined, @@ -461,7 +127,17 @@ function buildLocalProviderDrafts( ) as Record; } -export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefreshOnMount?: boolean }) { +export function ProvidersSection({ + forceRefreshOnMount = false, + providerParam = null, + onProviderChange, +}: { + forceRefreshOnMount?: boolean; + /** `?provider=` — which provider's page to show, if any. */ + providerParam?: string | null; + /** Lets the settings shell keep the URL in step with the sub-view. */ + onProviderChange?: (providerId: string | null) => void; +} = {}) { const navigate = useNavigate(); const [status, setStatus] = useState(null); const [projectConfigSnapshot, setProjectConfigSnapshot] = useState(null); @@ -491,12 +167,36 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh const [cursorAuth, setCursorAuth] = useState(null); const [cursorLoginBusy, setCursorLoginBusy] = useState(false); const [cursorLoginUrl, setCursorLoginUrl] = useState(null); + const [savingPermissionFor, setSavingPermissionFor] = useState(null); + const [savingDefaultModel, setSavingDefaultModel] = useState(false); + const [savingDisabledFor, setSavingDisabledFor] = useState(null); + // ACP CLI facts. Loaded when a provider's page opens, because reading them + // spawns the CLI — see `acpProviderDiagnostics` in main. + const [acpDiagnostics, setAcpDiagnostics] = useState>>({}); + const [acpDiagnosticsBusy, setAcpDiagnosticsBusy] = useState(null); + const [acpDoctorBusy, setAcpDoctorBusy] = useState(null); + const [acpDiagnosticsError, setAcpDiagnosticsError] = useState>>({}); + const [signInProvider, setSignInProvider] = useState(null); + // Which provider's page is open. Seeded and re-seeded from `?provider=`, but + // owned here so the section works standalone (and in tests) without a router + // that writes search params. + const [selectedProviderId, setSelectedProviderId] = useState(providerParam); const statusKnownRef = useRef(false); const pendingRefreshTimerRef = useRef(null); // Seed the slugs field from config exactly once — saves send the full list // (replace semantics), so the field must start from what's persisted or a // save would silently wipe existing entries. const slugsSeededRef = useRef(false); + + useEffect(() => { + setSelectedProviderId(providerParam); + }, [providerParam]); + + const selectProvider = useCallback((next: string | null) => { + setSelectedProviderId(next); + onProviderChange?.(next); + }, [onProviderChange]); + const revealClaudeLoginTerminalInWork = useCallback((terminal: { terminalId: string; laneId: string }) => { revealTerminalSessionInWork(navigate, terminal); }, [navigate]); @@ -558,11 +258,12 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh }, []); useEffect(() => { - void refreshStatus({ - force: forceRefreshOnMount, - refreshOpenCodeInventory: true, - }); - void loadAuthMethods(); + // Cold paint is disk auth only. OpenCode inventory is a spawn and shares + // the 30s runtime budget; OpenCode's Re-check still refreshes it. + void (async () => { + await refreshStatus({ force: forceRefreshOnMount }); + void loadAuthMethods(); + })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [forceRefreshOnMount]); @@ -613,18 +314,10 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh }, [refreshStatus]); const detectedAuth = useMemo(() => status?.detectedAuth ?? [], [status?.detectedAuth]); - const providerConnections = status?.providerConnections; - const piInstallation = status?.piInstallation ?? null; - const piConnection = providerConnections?.pi ?? null; - // Keep provider cards neutral while the status payload is unavailable. A - // failed first probe must not be presented as a real "Binary Missing" state. + // Keep provider tiles neutral while the status payload is unavailable. A + // failed first probe must not be presented as a real "Not installed" state. const isInitialCheckInFlight = status == null; - const piStatusLoadFailed = isInitialCheckInFlight && !loading && statusLoadError !== null; - const opencodeStatusKnown = status !== null; - const opencodeStatusLoadFailed = !opencodeStatusKnown && !loading && statusLoadError !== null; - const opencodeInstalled = status?.opencodeBinaryInstalled !== false; const opencodeProviders = useMemo(() => status?.opencodeProviders ?? [], [status?.opencodeProviders]); - const providersStale = status?.opencodeProvidersStale === true; const apiKeySources = useMemo(() => { const map = new Map(); @@ -646,7 +339,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh [apiKeySources, storedProviders], ); - const localRuntimes = useMemo(() => { + const localRuntimes = useMemo((): LocalRuntimeRow[] => { const availableModelIds = status?.availableModelIds ?? []; const runtimeConnections = status?.runtimeConnections ?? {}; return LOCAL_PROVIDER_SPECS.map((spec) => { @@ -791,19 +484,19 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh setDetailProviderId(id); }, []); - const beginEditing = (provider: string) => { + const beginEditing = useCallback((provider: string) => { setEditingProvider(provider); setEditValue(""); setError(null); setNotice(null); - }; + }, []); - const cancelEditing = () => { + const cancelEditing = useCallback(() => { setEditingProvider(null); setEditValue(""); - }; + }, []); - const deleteApiKey = async (provider: string, options?: { alsoOpenCode?: boolean }) => { + const deleteApiKey = useCallback(async (provider: string, options?: { alsoOpenCode?: boolean }) => { setError(null); setNotice(null); try { @@ -819,7 +512,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh API_KEY_PROVIDERS.find((row) => row.provider === provider)?.label ?? (provider === KIMI_PROVIDER_ID ? "Kimi for Coding" : prettifyProviderId(provider)); setNotice(`${label} disconnected.`); - if (editingProvider === provider) cancelEditing(); + setEditingProvider((current) => (current === provider ? null : current)); setVerificationByProvider((prev) => { const next = { ...prev }; delete next[provider]; @@ -831,9 +524,9 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh // Re-throw so nested modals (detail overlay) can show the failure in-dialog. throw err instanceof Error ? err : new Error(String(err)); } - }; + }, [refreshStatus]); - const verifyApiKey = async (provider: string) => { + const verifyApiKey = useCallback(async (provider: string) => { setError(null); setNotice(null); setVerifyingProvider(provider); @@ -858,9 +551,9 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } finally { setVerifyingProvider(null); } - }; + }, [refreshStatus]); - const saveCursorApiKey = async () => { + const saveCursorApiKey = useCallback(async () => { const trimmed = editValue.trim(); if (!trimmed) return; @@ -891,9 +584,9 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } finally { setVerifyingProvider(null); } - }; + }, [cancelEditing, editValue, refreshStatus]); - const loginWithCursor = async () => { + const loginWithCursor = useCallback(async () => { setError(null); setNotice(null); setCursorLoginBusy(true); @@ -921,9 +614,9 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh setCursorLoginBusy(false); setVerifyingProvider(null); } - }; + }, [refreshStatus]); - const logoutCursor = async () => { + const logoutCursor = useCallback(async () => { setError(null); setNotice(null); try { @@ -943,9 +636,9 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } catch (err) { setError(err instanceof Error ? err.message : String(err)); } - }; + }, [refreshStatus]); - const cancelCursorLogin = async () => { + const cancelCursorLogin = useCallback(async () => { try { await window.ade.ai.cursorAuthCancel(); } catch (err) { @@ -953,9 +646,9 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } finally { setCursorLoginBusy(false); } - }; + }, []); - const handleRefreshCatalog = async () => { + const handleRefreshCatalog = useCallback(async () => { setRefreshingCatalog(true); try { await window.ade.ai.refreshModelsDev(); @@ -965,7 +658,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } finally { setRefreshingCatalog(false); } - }; + }, [refreshStatus]); const handleSubscriptionConnected = useCallback(async (providerId: string, providerName: string) => { const before = status?.availableModelIds?.length ?? 0; @@ -982,7 +675,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh }); }, [status?.availableModelIds, refreshStatus, loadAuthMethods]); - const saveAdvancedProvider = async () => { + const saveAdvancedProvider = useCallback(async () => { const draft = customProviderDraft; const id = draft.id.trim(); const baseURL = draft.baseUrl.trim(); @@ -1022,9 +715,9 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } finally { setSavingAdvanced(false); } - }; + }, [customProviderDraft, refreshStatus, status?.customProviders]); - const saveCustomModelSlugs = async () => { + const saveCustomModelSlugs = useCallback(async () => { const slugs = customModelSlugs.split(",").map((s) => s.trim()).filter(Boolean); setSavingAdvanced(true); setError(null); @@ -1041,7 +734,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } finally { setSavingAdvanced(false); } - }; + }, [customModelSlugs, refreshStatus]); const updateLocalProviderDraft = useCallback(( provider: LocalProviderFamily, @@ -1095,8 +788,249 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh } }, [localProviderDrafts, refreshStatus]); + const permissionDefaults: AiProviderPermissions = useMemo( + () => projectConfigSnapshot?.effective.ai?.permissions?.providers ?? {}, + [projectConfigSnapshot], + ); + const disabledProviders = useMemo( + () => new Set((projectConfigSnapshot?.effective.ai?.disabledProviders ?? []).map((id) => id.toLowerCase())), + [projectConfigSnapshot], + ); + + const setProviderDisabled = useCallback(async ( + provider: SettingsProviderId, + disabled: boolean, + ) => { + const descriptor = providerDescriptor(provider); + setSavingDisabledFor(provider); + setError(null); + setNotice(null); + try { + // The whole authoritative list, not a delta: `mergeAiConfig` replaces + // this field, so a patch carrying only the change could never re-enable + // anything. + await window.ade.ai.updateConfig({ + disabledProviders: toggleDisabledProvider( + projectConfigSnapshot?.effective.ai ?? null, + provider, + disabled, + ), + } as Partial); + invalidateAiDiscoveryCache(); + setNotice(`${descriptor?.label ?? provider} ${disabled ? "disabled" : "enabled"}.`); + await refreshStatus({ force: false, silent: true }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingDisabledFor(null); + } + }, [projectConfigSnapshot, refreshStatus]); + + const loadAcpDiagnostics = useCallback(async (provider: AcpSettingsProviderId) => { + const read = window.ade.ai.acpProviderDiagnostics; + if (!read) return; + setAcpDiagnosticsBusy(provider); + try { + const result = await read({ provider }); + setAcpDiagnostics((prev) => ({ ...prev, [provider]: result })); + setAcpDiagnosticsError((prev) => { + const next = { ...prev }; + delete next[provider]; + return next; + }); + } catch (err) { + setAcpDiagnosticsError((prev) => ({ + ...prev, + [provider]: err instanceof Error ? err.message : String(err), + })); + } finally { + setAcpDiagnosticsBusy((current) => (current === provider ? null : current)); + } + }, []); + + const runAcpDoctor = useCallback(async (provider: AcpSettingsProviderId) => { + const read = window.ade.ai.acpProviderDiagnostics; + if (!read) { + setAcpDiagnosticsError((prev) => ({ ...prev, [provider]: "This window cannot run provider diagnostics." })); + return; + } + setAcpDoctorBusy(provider); + try { + const result = await read({ provider, runDoctor: true }); + setAcpDiagnostics((prev) => ({ ...prev, [provider]: result })); + setAcpDiagnosticsError((prev) => { + const next = { ...prev }; + delete next[provider]; + return next; + }); + } catch (err) { + setAcpDiagnosticsError((prev) => ({ + ...prev, + [provider]: err instanceof Error ? err.message : String(err), + })); + } finally { + setAcpDoctorBusy((current) => (current === provider ? null : current)); + } + }, []); + + const openSignInTerminal = useCallback((provider: SettingsProviderId) => { + const command = acpLoginCommand(provider); + if (!command) return; + setSignInProvider(provider); + }, []); + const defaultModelId = projectConfigSnapshot?.effective.ai?.defaultModel ?? null; + + const setPermissionDefault = useCallback(async ( + provider: SettingsProviderId, + mode: AgentChatPermissionMode, + ) => { + const descriptor = providerDescriptor(provider); + if (!descriptor) return; + setSavingPermissionFor(provider); + setError(null); + setNotice(null); + try { + // The detail page writes the ABSTRACT mode only. Translating it to each + // runtime's native flags stays where it already lives — one way, at + // launch — so this cannot drift from what a chat actually does. + await window.ade.ai.updateConfig({ + permissions: { providers: { [descriptor.permissions.key]: mode } }, + } as Partial); + setNotice(`${descriptor.label} permission default saved.`); + await refreshStatus({ force: false, silent: true }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingPermissionFor(null); + } + }, [refreshStatus]); + + const setDefaultModel = useCallback(async (modelId: string | null) => { + setSavingDefaultModel(true); + setError(null); + setNotice(null); + try { + await window.ade.ai.updateConfig({ defaultModel: (modelId ?? undefined) as AiConfig["defaultModel"] }); + invalidateAiDiscoveryCache(); + setNotice(modelId ? `Default model set to ${modelId}.` : "Default model cleared."); + await refreshStatus({ force: false, silent: true }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingDefaultModel(false); + } + }, [refreshStatus]); + + const ctx = useMemo((): ProvidersViewContext => ({ + status, + projectConfigSnapshot, + loading, + statusLoadError, + isInitialCheckInFlight, + storedProviders, + apiKeySources, + hasKeyFor, + verificationByProvider, + verifyingProvider, + editingProvider, + editValue, + cursorAuth, + cursorLoginBusy, + cursorLoginUrl, + authMethods, + authMethodsError, + openCodeCatalog, + connectedOpenCodeProviders, + popularOpenCodeProviders, + searchableOpenCodeProviders, + providerSearch, + refreshingCatalog, + localRuntimes, + localProviderDrafts, + editingLocalProvider, + savingLocalProvider, + customProviderDraft, + customModelSlugs, + savingAdvanced, + permissionDefaults, + savingPermissionFor, + defaultModelId, + savingDefaultModel, + disabledProviders, + savingDisabledFor, + acpDiagnostics, + acpDiagnosticsBusy, + acpDoctorBusy, + acpDiagnosticsError, + actions: { + refreshStatus, + loadAuthMethods, + setError, + setNotice, + beginEditing, + cancelEditing, + setEditValue, + deleteApiKey, + verifyApiKey, + saveCursorApiKey, + loginWithCursor, + logoutCursor, + cancelCursorLogin, + setProviderSearch, + refreshCatalog: handleRefreshCatalog, + openOpenCodeProviderDetail: openProviderDetail, + updateLocalProviderDraft, + beginEditingLocalRuntime, + cancelEditingLocalRuntime, + saveLocalProvider, + setCustomProviderDraft, + setCustomModelSlugs, + saveAdvancedProvider, + saveCustomModelSlugs, + setPermissionDefault, + setDefaultModel, + revealClaudeLoginTerminal: revealClaudeLoginTerminalInWork, + setProviderDisabled, + loadAcpDiagnostics, + runAcpDoctor, + openSignInTerminal, + }, + }), [ + apiKeySources, authMethods, authMethodsError, beginEditing, beginEditingLocalRuntime, cancelCursorLogin, cancelEditing, + cancelEditingLocalRuntime, connectedOpenCodeProviders, cursorAuth, cursorLoginBusy, + cursorLoginUrl, customModelSlugs, customProviderDraft, defaultModelId, deleteApiKey, editValue, + editingLocalProvider, editingProvider, handleRefreshCatalog, hasKeyFor, isInitialCheckInFlight, + loadAuthMethods, loading, localProviderDrafts, localRuntimes, loginWithCursor, logoutCursor, + openCodeCatalog, openProviderDetail, permissionDefaults, popularOpenCodeProviders, + projectConfigSnapshot, providerSearch, refreshStatus, refreshingCatalog, saveAdvancedProvider, + saveCursorApiKey, saveCustomModelSlugs, saveLocalProvider, savingAdvanced, savingDefaultModel, + savingLocalProvider, savingPermissionFor, searchableOpenCodeProviders, setDefaultModel, + setPermissionDefault, status, statusLoadError, storedProviders, updateLocalProviderDraft, + verificationByProvider, verifyApiKey, verifyingProvider, revealClaudeLoginTerminalInWork, + disabledProviders, savingDisabledFor, setProviderDisabled, acpDiagnostics, acpDiagnosticsBusy, + acpDoctorBusy, acpDiagnosticsError, loadAcpDiagnostics, runAcpDoctor, openSignInTerminal, + ]); + + const descriptors = useMemo(() => availableProviderDescriptors(), []); + const selectedDescriptor = selectedProviderId + ? descriptors.find((descriptor) => descriptor.id === selectedProviderId) ?? null + : null; + + // Opening an ACP provider's page is what pays for its CLI facts. Loading them + // for the grid would spawn four CLIs to draw four tiles. + const selectedAcpProvider = selectedDescriptor && acpLoginCommand(selectedDescriptor.id) + ? (selectedDescriptor.id as AcpSettingsProviderId) + : null; + useEffect(() => { + if (!selectedAcpProvider) return; + if (acpDiagnostics[selectedAcpProvider]) return; + void loadAcpDiagnostics(selectedAcpProvider); + }, [acpDiagnostics, loadAcpDiagnostics, selectedAcpProvider]); + + const signInCommand = signInProvider ? acpLoginCommand(signInProvider) : null; + return ( -
+
{notice && ( setNotice(null)} /> )} @@ -1113,678 +1047,55 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh /> )} - {/* ══ Coding Agents ══ */} -
-
Coding Agents
- - {/* ── Claude Code ── */} - {(() => { - const tool = CLI_TOOLS.find((t) => t.cli === "claude")!; - const connection = providerConnections?.[tool.cli] ?? null; - const availability = status?.availableProviders?.claude ?? null; - const credentialSourceDesc = describeCredentialSource(connection); - const tone = isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getClaudeAvailabilityTone(availability); - const message = isInitialCheckInFlight ? "Checking Claude SDK binary and login status." : buildClaudeAvailabilityMessage(availability); - const binaryPath = availability?.binary.path ?? connection?.path ?? null; - return ( -
-
-
- -
-
Claude Code
-
{tool.authStory}
-
-
-
- {isInitialCheckInFlight ? : availability?.auth.ready ? : availability?.binary.present ? : } - {tone.label} -
-
-
{message}
- {!isInitialCheckInFlight && availability?.binary.present && !availability.auth.ready ? ( -
- -
- ) : null} - {credentialSourceDesc && !availability?.auth.ready && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} - {binaryPath && !isInitialCheckInFlight ? {binaryPath} : null} -
- ); - })()} - - {/* ── Codex CLI ── */} - {(() => { - const tool = CLI_TOOLS.find((t) => t.cli === "codex")!; - const connection = providerConnections?.[tool.cli] ?? null; - const credentialSourceDesc = describeCredentialSource(connection); - const tone = isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getStatusTone(connection); - const message = isInitialCheckInFlight ? "Checking CLI availability and login status." : buildCliMessage(tool, connection); - return ( -
-
-
- -
-
Codex CLI
-
{tool.authStory}
-
-
-
- {isInitialCheckInFlight ? : connection?.runtimeAvailable ? : connection?.authAvailable || connection?.runtimeDetected ? : } - {tone.label} -
-
-
{message}
- {credentialSourceDesc && !connection?.runtimeAvailable && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} - {connection?.path && !isInitialCheckInFlight ? {connection.path} : null} -
- ); - })()} - - {/* ── Cursor ── */} - {/* Hidden entirely on Windows on ARM: @cursor/sdk has no win32-arm64 - build, so the card could only ever offer a provider that cannot - start. See shared/providerPlatformSupport.ts. */} - {!cursorProviderAvailable() ? null : (() => { - const tool = CLI_TOOLS.find((t) => t.cli === "cursor")!; - const connection = providerConnections?.[tool.cli] ?? null; - const credentialSourceDesc = describeCredentialSource(connection); - const keySource = apiKeySources.get("cursor") ?? (storedProviders.includes("cursor") ? "store" : undefined); - const verification = verificationByProvider.cursor; - const isEditing = editingProvider === "cursor"; - const isVerifying = verifyingProvider === "cursor"; - const isVerified = !isVerifying && verification?.ok; - const isInvalid = !isVerifying && verification && !verification.ok; - const isKeyConnected = Boolean(isVerified || (!isInvalid && keySource && connection?.runtimeAvailable)); - const signedInEmail = (cursorAuth?.email ?? connection?.accountEmail)?.trim() || null; - const oauthSignedIn = Boolean( - cursorAuth?.sdkStatus === "logged-in" - || cursorAuth?.credentialSource === "cursor-oauth" - || connection?.sources.some((entry) => entry.source === "cursor-oauth"), - ); - const loginUrl = cursorLoginUrl ?? cursorAuth?.loginUrl ?? null; - const tone = isVerifying - ? { color: COLORS.info, label: "Verifying" } - : isVerified - ? { color: COLORS.success, label: "Connected" } - : isInvalid - ? { color: COLORS.danger, label: "Verification failed" } - : isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getStatusTone(connection); - const message = isVerifying - ? "Verifying Cursor API key with the Cursor SDK." - : isVerified - ? "Cursor SDK connected. ADE uses this key for Cursor chat and Cursor Cloud agents." - : isInvalid - ? verification.message - : isInitialCheckInFlight ? "Checking Cursor SDK API key." : (connection?.blocker ?? "Sign in with Cursor or enter a Cursor API key."); - return ( -
-
-
- -
-
Cursor
-
{tool.authStory}
-
-
-
- {isVerifying || isInitialCheckInFlight ? : isVerified ? : isInvalid ? : connection?.runtimeAvailable ? : connection?.authAvailable || connection?.runtimeDetected ? : } - {tone.label} -
-
-
{message}
- {credentialSourceDesc && !connection?.runtimeAvailable && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} -
-
-
Sign in with Cursor
-
- Opens a browser and mints a Cursor API key for ADE. Does not copy Cursor IDE cookies. -
- {oauthSignedIn ? ( -
-
- - {signedInEmail ? `Signed in as ${signedInEmail}` : "Signed in with Cursor"} -
- -
- ) : ( -
- - {cursorLoginBusy ? ( - - ) : null} -
- )} - {loginUrl && cursorLoginBusy ? ( -
-
- If a browser did not open, copy this URL: -
- -
- ) : null} -
-
-
API key
-
CURSOR_API_KEY
-
-
- {isEditing ? ( - setEditValue(event.target.value)} - placeholder="crsr_..." - type="password" - disabled={isVerifying} - style={{ width: "100%", background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} - /> - ) : keySource ? ( -
- - {isVerifying ? ( - - - Verifying... - - ) : isKeyConnected ? ( - - - Connected - - ) : verification ? ( - - {verification.ok ? : } - {verification.ok ? "Verified" : verification.message} - - ) : ( - - {keySource === "env" ? "Loaded from environment" : keySource === "config" ? "Defined in project config" : "Stored locally"} - - )} -
- ) : ( - No Cursor API key configured - )} -
-
- {isEditing ? ( - <> - - - - ) : keySource ? ( - <> - {isKeyConnected ? ( - - ) : ( - - )} - {keySource === "store" ? ( - <> - - - - ) : null} - - ) : ( - - )} -
-
-
-
-
- ); - })()} - - {/* ── Droid ── */} - {(() => { - const tool = CLI_TOOLS.find((t) => t.cli === "droid")!; - const connection = providerConnections?.[tool.cli] ?? null; - const credentialSourceDesc = describeCredentialSource(connection); - const tone = isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getStatusTone(connection); - const message = isInitialCheckInFlight ? "Checking CLI availability and login status." : buildCliMessage(tool, connection); - return ( -
-
-
- -
-
Droid
-
{tool.authStory}
-
-
-
- {isInitialCheckInFlight ? : connection?.runtimeAvailable ? : connection?.authAvailable || connection?.runtimeDetected ? : } - {tone.label} -
-
-
{message}
- {credentialSourceDesc && !connection?.runtimeAvailable && !isInitialCheckInFlight ?
{credentialSourceDesc}
: null} - {connection?.path && !isInitialCheckInFlight ? {connection.path} : null} -
- ); - })()} - - {(() => { - const tone = piStatusLoadFailed - ? { color: COLORS.danger, label: "Unavailable" } - : isInitialCheckInFlight ? { color: COLORS.info, label: "Checking" } : getPiTone(piConnection, piInstallation); - const message = piStatusLoadFailed - ? `Could not load Pi status: ${statusLoadError}` - : isInitialCheckInFlight - ? "Checking Pi installation and provider inventory." - : buildPiMessage(piConnection, piInstallation); - return ( - } - accentColor={tone.color} - status={( -
- {isInitialCheckInFlight ? : piConnection?.runtimeAvailable ? : piConnection?.authAvailable || piConnection?.runtimeDetected ? : } - {tone.label} -
- )} - > -
{message}
- {piStatusLoadFailed ? ( - - ) : null} - {piInstallation?.error ? ( -
- Inventory fallback: {piInstallation.error} -
- ) : null} - {piInstallation?.version ? ( -
- Version {piInstallation.version}{piInstallation.stale ? " · cached" : ""} -
- ) : null} - {piConnection?.path && !isInitialCheckInFlight ? {piConnection.path} : null} - - {piInstallation ? ( - void refreshStatus({ force: true })} - onRefreshStatus={() => void refreshStatus({ force: true })} - /> - ) : null} - - {piInstallation ? ( -
- {!piInstallation.sdkAvailable ? ( - - ) : null} - {piInstallation.settingsFileDetected ? ( - - ) : ( - settings.json not found - )} - {piInstallation.authFileDetected ? ( - - ) : ( - auth.json not found - )} - {piInstallation.modelsFileDetected ? ( - - ) : ( - models.json not found - )} -
- ) : null} -
- ); - })()} -
- - {/* ══ OpenCode — Universal Model Access ══ */} -
- {/* Group header */} -
-
- -
-
- OpenCode — Universal Model Access -
-
- SuperGrok OAuth, ChatGPT, Copilot, or API keys — same /connect providers as OpenCode -
-
-
-
- {!opencodeStatusKnown ? (opencodeStatusLoadFailed ? : ) : !opencodeInstalled ? : status?.opencodeInventoryError ? : } - - {!opencodeStatusKnown ? (opencodeStatusLoadFailed ? "Error" : "Checking") : !opencodeInstalled ? "Not found" : status?.opencodeInventoryError ? "Error" : "Installed"} - -
+ {selectedDescriptor ? ( +
+ selectProvider(null)} + /> +
+ ) : ( +
+
Providers
+ {/* 320, not 280: at this window width 280 fits five columns, and five + columns is where "GitHub Copilot" stopped fitting on one line. */} + + {descriptors.map((descriptor) => ( + selectProvider(descriptor.id)} + /> + ))} +
+ )} - {!opencodeStatusKnown && opencodeStatusLoadFailed ? ( -
-
- Could not load OpenCode status. -
- -
- ) : !opencodeStatusKnown ? ( -
- Checking OpenCode and its provider catalog… -
- ) : !opencodeInstalled ? ( - /* Collapsed install card */ -
-
- OpenCode powers every subscription, API key, and local model below. Install it, then re-check: -
-
- {openCodeInstallCommands().map((cmd) => ( - - ))} -
-
- -
-
- ) : ( -
-
- {providersStale ? ( - - Updating provider catalog… - - ) : null} - -
- -
- {/* ── Connected ── */} -
-
Connected
- {connectedOpenCodeProviders.length === 0 ? ( -
- No providers connected yet. Pick one below to sign in or add a key. -
- ) : ( - - {connectedOpenCodeProviders.map((row) => ( - openProviderDetail(row.id)} /> - ))} - - )} -
- - {/* ── All providers ── */} -
-
-
All providers · {openCodeCatalog.length}
- -
- - {!providerSearch.trim() ? ( - <> -
Popular
- - {popularOpenCodeProviders.map((row) => ( - openProviderDetail(row.id)} /> - ))} - - - ) : searchableOpenCodeProviders.length === 0 ? ( -
- No providers match your search. -
- ) : ( - - {searchableOpenCodeProviders.map((row) => ( - openProviderDetail(row.id)} /> - ))} - - )} -
- - {/* ── Local Model Servers ── */} -
-
-
Local Model Servers
- -
-
- {localRuntimes.map((entry) => { - const isEditing = editingLocalProvider === entry.provider; - const isSaving = savingLocalProvider === entry.provider; - const draft = localProviderDrafts[entry.provider]; - const hasReadyRuntime = entry.runtimeAvailable || (entry.detected && entry.hasModels); - const needsModelLoad = !hasReadyRuntime && !entry.hasModels && (entry.health === "reachable" || entry.health === "reachable_no_models"); - const tone = hasReadyRuntime - ? { color: COLORS.success, label: entry.hasModels ? "Ready" : "Connected" } - : needsModelLoad - ? { color: COLORS.warning, label: "Load a model" } - : entry.blocker - ? { color: COLORS.warning, label: "Blocked" } - : { color: COLORS.warning, label: "Not detected" }; - const loadedModels = entry.modelIds.slice(0, 4); - const extraModelCount = Math.max(0, entry.modelIds.length - loadedModels.length); - const message = entry.blocker - ? entry.blocker - : entry.detected - ? entry.hasModels - ? `${entry.label} is reachable at ${entry.endpoint}. ADE can use ${entry.modelIds.length} loaded model${entry.modelIds.length === 1 ? "" : "s"} from this runtime${entry.health ? ` (${entry.health})` : ""}.` - : `${entry.label} responded, but no loaded models were reported yet. Load a model in ${entry.label} and refresh.` - : `${entry.label} was not detected. Start it, load at least one model, then refresh so ADE can discover its OpenAI-compatible server.`; - - return ( -
-
-
- -
-
{entry.label}
-
{entry.description}
-
-
-
- {hasReadyRuntime ? : needsModelLoad || entry.blocker ? : } - {tone.label} -
-
- -
{message}
- - - {draft?.endpoint?.trim() || entry.endpoint} - - -
- {loadedModels.length > 0 ? ( - <> - {loadedModels.map((modelId) => ( - - - {formatLocalModelLabel(modelId)} - - ))} - {extraModelCount > 0 ? ( - - +{extraModelCount} more - - ) : null} - - ) : ( - No loaded models reported yet. - )} -
- - {isEditing && draft ? ( -
- - - - -
- ) : null} - -
- {isEditing ? ( - <> - - - - ) : ( - <> - - - - )} -
-
- ); - })} -
-
- - {/* ── e. Advanced ── */} -
- - Advanced — custom providers & model slugs - -
- {/* Custom provider */} -
-
Custom provider
-
- setCustomProviderDraft((d) => ({ ...d, id: e.target.value }))} placeholder="provider-id" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> - setCustomProviderDraft((d) => ({ ...d, name: e.target.value }))} placeholder="Display name" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: SANS_FONT, color: COLORS.textPrimary, outline: "none" }} /> - setCustomProviderDraft((d) => ({ ...d, baseUrl: e.target.value }))} placeholder="https://api.example.com/v1" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> - - setCustomProviderDraft((d) => ({ ...d, slugs: e.target.value }))} placeholder="model-a, model-b" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> - setCustomProviderDraft((d) => ({ ...d, apiKey: e.target.value }))} placeholder="API key (optional)" type="password" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> -
-
- -
-
- - {/* Custom model slugs */} -
-
Custom model slugs
- setCustomModelSlugs(e.target.value)} placeholder="provider/model-a, provider/model-b" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> -
- -
-
-
-
-
-
- )} -
+ {signInProvider && signInCommand ? ( + { + setSignInProvider(null); + void refreshStatus({ force: true, silent: true }); + }} + onSignedIn={() => { + setNotice(`Signed in to ${acpProviderLabel(signInProvider) ?? signInProvider}.`); + invalidateAiDiscoveryCache(); + }} + checkSignedIn={async () => { + const next = await refreshStatus({ force: true, silent: true }); + // `authAvailable` is the disk credential the login just wrote — + // that is the moment worth closing on. `runtimeAvailable` also + // waits on the protocol probe, which is right for the tile and too + // slow for a dialog someone is watching. + return next?.providerConnections?.[signInProvider as AcpSettingsProviderId]?.authAvailable === true; + }} + /> + ) : null} {detailProvider ? ( {children} @@ -96,6 +105,11 @@ export function ProviderTile({ badge, ariaLabel, footer, + logo, + accentColor, + padding = 10, + minHeight = 72, + wrapName = false, onOpen, }: { id: string; @@ -104,6 +118,21 @@ export function ProviderTile({ badge: React.ReactNode; ariaLabel: string; footer?: React.ReactNode; + /** Overrides the generic brand mark — the six first-class providers have their own. */ + logo?: React.ReactNode; + /** Left status rule, used by the top-level provider grid. */ + accentColor?: string; + padding?: number; + minHeight?: number; + /** + * Let a long name take a second line instead of clipping to an ellipsis. + * + * A dense sub-catalog of forty vendors wants every row the same height, so it + * clips. The top-level provider grid is the opposite case: there are ten + * cards, the name is the card's identity, and "GitHub Co…" is a worse tile + * than a two-line title. + */ + wrapName?: boolean; onOpen: () => void; }) { return ( @@ -112,28 +141,31 @@ export function ProviderTile({ onClick={onOpen} aria-label={ariaLabel} style={{ - ...panel({ padding: 10 }), + ...panel({ padding }), + ...(accentColor ? { borderLeft: `3px solid ${accentColor}` } : {}), display: "flex", flexDirection: "column", gap: 8, textAlign: "left", cursor: "pointer", width: "100%", - minHeight: 72, + minHeight, background: COLORS.recessedBg, }} >
-
- +
+ {logo ?? } {name} diff --git a/apps/desktop/src/renderer/components/settings/providers/ProviderDetailPage.tsx b/apps/desktop/src/renderer/components/settings/providers/ProviderDetailPage.tsx new file mode 100644 index 0000000000..a987cc34c0 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/ProviderDetailPage.tsx @@ -0,0 +1,362 @@ +/** + * One provider's page. Two columns: what it is and how to sign in on the left, + * what it can do on the right. Everything on it comes from the descriptor, so + * every provider's page has the same shape and the same status vocabulary. + */ +import React, { useMemo, useState } from "react"; +import { ArrowLeft, MagnifyingGlass, Star } from "@phosphor-icons/react"; +import { Link } from "react-router-dom"; +import { + COLORS, + SANS_FONT, + SECTION_LABEL_STYLE, + outlineButton, +} from "../../lanes/laneDesignTokens"; +import { PermissionModePicker } from "../../shared/PermissionModePicker"; +import { getPermissionOptions } from "../../shared/permissionOptions"; +import { toPermissionPickerOption } from "../../chat/crossMachineHandoffPresentation"; +import type { AgentChatPermissionMode } from "../../../../shared/types"; +import { settingsRouteFor } from "../settingsManifest"; +import { panel } from "../providerSectionPrimitives"; +import { + CopyReportButton, + PathLine, + PreviewChip, + ProviderErrorRow, + ProviderStatusChip, + SubsectionTitle, + normalizeProviderVersion, + providerStatusColor, +} from "./providerUi"; +import { providerStatusFor } from "./descriptors"; +import { formatProviderDiagnosticsReport } from "./providerDiagnosticsReport"; +import type { AcpSettingsProviderId, ProviderDescriptor, ProvidersViewContext } from "./types"; + +/** + * Eight rows, then scroll. + * + * A row is 11px text on 6px of padding top and bottom plus a hairline — + * 6 + 17 + 6 + 1 = 30px. Eight of those is 240, and the half-row the ninth + * shows through the cut is the cue that there is more. This is a cap, not a + * preference: OpenCode reports 83 models and Cursor 36, and a panel that grew + * with them pushed everything else on the page below the fold. + */ +const MODEL_ROW_HEIGHT = 30; +const MODEL_LIST_MAX_HEIGHT = MODEL_ROW_HEIGHT * 8; + +function ModelsPanel({ + descriptor, + ctx, +}: { + descriptor: ProviderDescriptor; + ctx: ProvidersViewContext; +}) { + const [query, setQuery] = useState(""); + const models = descriptor.models(ctx); + const status = providerStatusFor(descriptor, ctx); + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return models; + return models.filter((model) => model.id.toLowerCase().includes(q) || model.label.toLowerCase().includes(q)); + }, [models, query]); + + return ( +
+
+
Models · {models.length}
+ {/* Always present, never conditional on list length: the field moving in + and out as a provider's catalog changes size is worse than a field + that is occasionally unnecessary. */} +
+ + setQuery(event.target.value)} + placeholder="Search models" + style={{ flex: 1, minWidth: 0, background: "transparent", border: "none", outline: "none", fontSize: 11, fontFamily: SANS_FONT, color: COLORS.textPrimary }} + /> +
+
+ + {/* The model list IS the health check: an enumerate that failed says so + here, in place of a Verify button that would only ask again. Suppressed + when the left rail already says exactly this — one sentence, once. */} + {status.errorLine && status.errorLine !== status.message + ? + : null} + + {filtered.length === 0 ? ( +
+ {status.state === "checking" + ? "Checking…" + : models.length === 0 + ? "No models reported yet." + : "No models match your search."} +
+ ) : ( +
+ {filtered.map((model) => ( +
+ {model.isDefault ? ( + + ) : ( + + )} + + {model.label} + + {model.label !== model.id ? ( + + {model.id} + + ) : null} +
+ ))} +
+ )} +
+ ); +} + +function DefaultsPanel({ + descriptor, + ctx, +}: { + descriptor: ProviderDescriptor; + ctx: ProvidersViewContext; +}) { + const options = useMemo( + () => getPermissionOptions({ + family: descriptor.permissions.family, + isCliWrapped: descriptor.permissions.isCliWrapped, + }).map(toPermissionPickerOption), + [descriptor], + ); + const current = (ctx.permissionDefaults[descriptor.permissions.key] as AgentChatPermissionMode | undefined) + ?? options[0]?.value as AgentChatPermissionMode; + const models = descriptor.models(ctx); + const defaultModelIsThisProvider = ctx.defaultModelId != null + && models.some((model) => model.id === ctx.defaultModelId); + + return ( +
+
+
Permission default
+
+ What new {descriptor.label} chats start with. Each chat can still change it. +
+
+ void ctx.actions.setPermissionDefault(descriptor.id, value as AgentChatPermissionMode)} + /> + {ctx.savingPermissionFor === descriptor.id ? ( + Saving… + ) : null} +
+
+ +
+
Default model
+
+ ADE has one default model across providers. + {ctx.defaultModelId && !defaultModelIsThisProvider ? ` It is currently ${ctx.defaultModelId}.` : ""} +
+ +
+
+ ); +} + +export function ProviderDetailPage({ + descriptor, + ctx, + onBack, +}: { + descriptor: ProviderDescriptor; + ctx: ProvidersViewContext; + onBack: () => void; +}) { + const status = providerStatusFor(descriptor, ctx); + const disabled = ctx.disabledProviders.has(descriptor.id); + const version = normalizeProviderVersion(descriptor.version?.(ctx)); + const facts = descriptor.facts?.(ctx) ?? []; + const AuthActions = descriptor.AuthActions; + const Diagnostics = descriptor.Diagnostics; + const Body = descriptor.Body; + const diagnosticReport = formatProviderDiagnosticsReport({ + label: descriptor.label, + status, + version, + facts, + acp: ctx.acpDiagnostics[descriptor.id as AcpSettingsProviderId] ?? null, + }); + + return ( +
+ + +
+ {/* ── Left rail: identity, status, auth ── */} +
+
+
+ {descriptor.logo(26)} +
+
+
+ {descriptor.label} +
+ {descriptor.preview ? : null} +
+
+ {descriptor.tagline} +
+
+
+ + + +
+ {status.message} +
+ + {version ? ( +
+ Version {version} +
+ ) : null} + + {facts.map((fact) => ( +
+
{fact.label}
+ {fact.mono ? ( + + ) : ( +
+ {fact.value} +
+ )} +
+ ))} +
+ + {/* Sign in stays reachable while disabled: switching a provider off is + about what ADE offers, not about locking you out of its account. */} + {AuthActions ? ( +
+ Sign in + +
+ ) : null} + +
+ Availability +
+ {disabled + ? `${descriptor.label} is switched off. Turn it back on to offer its models again.` + : `Turn ${descriptor.label} off to keep its models out of every picker on this machine.`} +
+ +
+ +
+ Troubleshooting + + {Diagnostics ? : null} + + + Open diagnostics + +
+
+ + {/* ── Right: what it can do ── */} +
+ + + {Body ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/providers/ProviderSignInModal.tsx b/apps/desktop/src/renderer/components/settings/providers/ProviderSignInModal.tsx new file mode 100644 index 0000000000..a0c7d5b4e0 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/ProviderSignInModal.tsx @@ -0,0 +1,288 @@ +/** + * Sign in to a provider without leaving the page you asked from. + * + * A provider login is a terminal flow — a device code to read, a URL to open, a + * prompt to answer — so this hosts a real PTY running the vendor's own login + * command rather than reimplementing four OAuth dances ADE does not own. It is + * the same `window.ade.pty.create` the Claude login button uses and the same + * `TerminalView` the Work tab renders; only the frame is new. + * + * Three behaviours make it feel finished: + * + * - the first OAuth URL in the output is opened in a browser, once, so nobody + * has to copy a link out of a terminal; + * - the provider's auth status is re-read while the terminal is open, and the + * modal closes itself a beat after it turns green; + * - closing by hand always works, and always disposes the PTY — a login shell + * left running behind a closed dialog is a leak nobody can see. + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { CheckCircle, X } from "@phosphor-icons/react"; +import { COLORS, MONO_FONT, SANS_FONT, outlineButton } from "../../lanes/laneDesignTokens"; +import { TerminalView } from "../../terminals/TerminalView"; +import { openExternalUrl } from "../../../lib/openExternal"; + +/** + * Poll cadence for "are we signed in yet". + * + * Each tick is a forced status refresh, which re-reads credentials off disk — + * cheap, and it is the disk write that a completed ` login` produces. The + * protocol probe behind it keeps its own TTL so this cannot become a spawn + * storm. + */ +const AUTH_POLL_MS = 4_000; +/** How long the success state stays up before the modal closes itself. */ +const SUCCESS_HOLD_MS = 1_200; + +const URL_PATTERN = /https?:\/\/[^\s"'<>()\]]+/g; + +/** + * The first sign-in URL in some terminal output, if there is one. + * + * Deliberately narrow: a login flow prints exactly one link worth opening, and + * auto-opening a docs link or an update notice would hijack the browser for no + * reason. + */ +export function findSignInUrl(text: string): string | null { + const matches = text.match(URL_PATTERN); + if (!matches?.length) return null; + for (const raw of matches) { + const url = raw.replace(/[.,;:]+$/, ""); + if (/(auth|login|oauth|device|verify|activate|connect|sso)/i.test(url)) return url; + } + return null; +} + +export type ProviderSignInModalProps = { + providerId: string; + providerLabel: string; + /** The command the terminal runs, e.g. `kimi login`. */ + command: string; + /** Lane to open the terminal in. Resolved by the caller; null asks for the primary lane. */ + laneId?: string | null; + /** Re-read auth status. Resolves true once this provider is signed in. */ + checkSignedIn: () => Promise; + onClose: () => void; + /** Called once, after a successful sign-in, so the caller can refresh its own view. */ + onSignedIn?: () => void; +}; + +export function ProviderSignInModal({ + providerId, + providerLabel, + command, + laneId, + checkSignedIn, + onClose, + onSignedIn, +}: ProviderSignInModalProps) { + const [terminal, setTerminal] = useState<{ ptyId: string; sessionId: string } | null>(null); + const [error, setError] = useState(null); + const [signedIn, setSignedIn] = useState(false); + const openedUrlRef = useRef(null); + const terminalRef = useRef<{ ptyId: string; sessionId: string } | null>(null); + const closedRef = useRef(false); + + const title = useMemo(() => `Sign in to ${providerLabel}`, [providerLabel]); + + // ── Open the PTY ── + useEffect(() => { + let cancelled = false; + void (async () => { + try { + if (!window.ade?.pty?.create) throw new Error("Terminals are not available in this window."); + const lanes = await window.ade.lanes.list({ + includeArchived: false, + includeStatus: false, + }); + const resolvedLaneId = laneId ?? lanes.find((lane) => lane.laneType === "primary")?.id + ?? lanes[0]?.id + ?? null; + if (!resolvedLaneId) throw new Error("No lane is available to run the login in."); + const created = await window.ade.pty.create({ + laneId: resolvedLaneId, + cols: 100, + rows: 24, + title, + // Not tracked: this is a one-shot login shell, not an agent session, + // and tracking it would put a phantom row in Work. + tracked: false, + toolType: "shell", + startupCommand: command, + }); + if (cancelled) { + void window.ade.pty.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + return; + } + terminalRef.current = { ptyId: created.ptyId, sessionId: created.sessionId }; + setTerminal({ ptyId: created.ptyId, sessionId: created.sessionId }); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } + })(); + return () => { + cancelled = true; + }; + }, [command, laneId, title]); + + // ── Dispose on unmount, always ── + useEffect(() => () => { + const open = terminalRef.current; + terminalRef.current = null; + if (open) void window.ade?.pty?.dispose({ ptyId: open.ptyId, sessionId: open.sessionId }); + }, []); + + // ── Open the first sign-in URL we see, once ── + useEffect(() => { + if (!terminal || !window.ade?.pty?.onData) return; + const unsubscribe = window.ade.pty.onData((event) => { + if (event.ptyId !== terminal.ptyId) return; + if (openedUrlRef.current) return; + const url = findSignInUrl(event.data ?? ""); + if (!url) return; + openedUrlRef.current = url; + openExternalUrl(url); + }); + return unsubscribe; + }, [terminal]); + + // ── Watch for the auth status flipping green ── + useEffect(() => { + if (!terminal || signedIn) return; + let stopped = false; + const timer = setInterval(() => { + void checkSignedIn() + .then((ok) => { + if (stopped || !ok) return; + setSignedIn(true); + onSignedIn?.(); + }) + .catch(() => { + // A failed re-check is not a failed login. Keep watching; the manual + // close is always there. + }); + }, AUTH_POLL_MS); + return () => { + stopped = true; + clearInterval(timer); + }; + }, [checkSignedIn, onSignedIn, signedIn, terminal]); + + const close = useCallback(() => { + if (closedRef.current) return; + closedRef.current = true; + onClose(); + }, [onClose]); + + // ── Success beat, then close ── + useEffect(() => { + if (!signedIn) return; + const timer = setTimeout(close, SUCCESS_HOLD_MS); + return () => clearTimeout(timer); + }, [close, signedIn]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") close(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [close]); + + return createPortal( +
+
event.stopPropagation()} + > +
+
+
+ {title} +
+
{command}
+
+ +
+ + {signedIn ? ( +
+ + Signed in to {providerLabel}. +
+ ) : null} + +
+ {error ? ( +
+ {error} +
+ ) : terminal ? ( + + ) : ( +
+ Starting a terminal… +
+ )} +
+
+
, + document.body, + ); +} diff --git a/apps/desktop/src/renderer/components/settings/providers/ProviderTileCard.tsx b/apps/desktop/src/renderer/components/settings/providers/ProviderTileCard.tsx new file mode 100644 index 0000000000..782e86660c --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/ProviderTileCard.tsx @@ -0,0 +1,109 @@ +/** One provider on the top-level grid. Reads only the descriptor. */ +import React from "react"; +import { COLORS, SANS_FONT } from "../../lanes/laneDesignTokens"; +import { ProviderTile } from "../providerSectionPrimitives"; +import { + PreviewChip, + ProviderStatusChip, + normalizeProviderVersion, + providerStatusColor, +} from "./providerUi"; +import { providerStatusFor } from "./descriptors"; +import type { ProviderDescriptor, ProvidersViewContext } from "./types"; + +/** + * Four fixed rows: name, status, meta, message. + * + * 12px padding top and bottom (24) + a 22px logo row + 8px gap + the three + * footer rows (16 + 6 + 14 + 6 + 28) comes to 124. Every tile reserves all four + * whether or not it has something for the last one, because the alternative — + * sizing to content — is what gave the healthy tiles a dead band and made the + * grid look broken next to a tile with an error. + */ +const TILE_MIN_HEIGHT = 124; +/** Two lines at 10px · 1.4. The error line used to clamp to one and cut mid-sentence. */ +const MESSAGE_ROW_HEIGHT = 28; + +export function ProviderTileCard({ + descriptor, + ctx, + onOpen, +}: { + descriptor: ProviderDescriptor; + ctx: ProvidersViewContext; + onOpen: () => void; +}) { + const status = providerStatusFor(descriptor, ctx); + const models = descriptor.models(ctx); + const version = normalizeProviderVersion(descriptor.version?.(ctx)); + // A count of zero while the probe is still out is a claim we cannot make. + // A disabled provider's count is real but beside the point — the tile's job + // is to say it is off and to be clickable. + const showModelCount = status.state !== "checking" && status.state !== "disabled"; + + // The fourth row says one of two things. A provider in trouble gets the real + // status sentence; a healthy one gets where its credential came from, which + // is the only question a working provider still raises. + const healthy = status.state === "connected"; + const problem = status.errorLine ?? (healthy ? null : status.message); + const message = healthy ? descriptor.credentialLine?.(ctx) ?? null : problem; + + const metaParts = [ + ...(showModelCount ? [`${models.length} model${models.length === 1 ? "" : "s"}`] : []), + ...(version ? [version] : []), + ]; + + return ( + : null} + onOpen={onOpen} + footer={ +
+
+ +
+
+ {metaParts.join(" · ")} +
+
+ {message} +
+
+ } + /> + ); +} diff --git a/apps/desktop/src/renderer/components/settings/providers/acpProviders.tsx b/apps/desktop/src/renderer/components/settings/providers/acpProviders.tsx new file mode 100644 index 0000000000..b3ea32f528 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/acpProviders.tsx @@ -0,0 +1,356 @@ +/** + * The four providers ADE reaches over the Agent Client Protocol. + * + * They share a host, so they share almost everything here: one status reader, + * one facts builder, one auth-actions component. What differs per provider is + * data — the label, the login command, the config-home env var, and the one + * honest-degradation note Kimi needs — so it lives in a table rather than in + * four near-identical descriptors. + */ +import React from "react"; +import { COLORS, MONO_FONT, SANS_FONT, outlineButton } from "../../lanes/laneDesignTokens"; +import { ProviderLogo } from "../../shared/ProviderLogos"; +import { listModelDescriptorsForProvider, providerTierIsPreview } from "../../../../shared/modelRegistry"; +import { ACP_PROVIDER_METADATA } from "../../../../shared/acpProviderMetadata"; +import { CopyableCommand, SubsectionTitle } from "./providerUi"; +import type { + AcpSettingsProviderId, + ProviderDescriptor, + ProviderFact, + ProviderModelRow, + ProviderStatusView, + ProvidersViewContext, +} from "./types"; +import { statusProbeFailed } from "./types"; + +type AcpProviderSpec = { + id: AcpSettingsProviderId; + label: string; + tagline: string; + /** Family the logo table keys off. */ + logoFamily: string; + /** What to run in a terminal to sign in. Also what the embedded modal runs. */ + loginCommand: string; + /** How to install it, per the vendor's own docs. */ + installCommand: string; + /** Environment variable that names its config directory, when it has one. */ + configHomeEnv: string | null; + /** Where credentials come from, in one plain sentence. */ + credentialSource: string; + /** + * How to set this CLI up on the machine. ADE reuses the vendor install; it + * does not configure these four for you. + */ + setup: string; + /** One line about a capability this provider does not have. Rendered plainly. */ + degradation?: string; +}; + +export const ACP_PROVIDER_SPECS: readonly AcpProviderSpec[] = [ + { + ...ACP_PROVIDER_METADATA.qwen, + id: "qwen", + tagline: "Uses the Qwen Code CLI you already set up.", + logoFamily: "qwen", + installCommand: "npm install -g @qwen-code/qwen-code", + credentialSource: "OPENAI_API_KEY (and optional OPENAI_BASE_URL), a custom provider in ~/.qwen/settings.json, or `qwen --auth-type=openai`. The `qwen auth` subcommand is removed in 0.22.3.", + setup: "Install Qwen Code and configure it in that CLI. ADE does not write ~/.qwen. Point Qwen at DashScope, OpenRouter, or any OpenAI-compatible server (OPENAI_BASE_URL plus a dummy or real key). Models you add with /model show up here after a refresh.", + }, + { + ...ACP_PROVIDER_METADATA.kimi, + id: "kimi", + tagline: "Uses your Moonshot account through the kimi CLI.", + logoFamily: "kimi", + installCommand: "curl -LsSf https://code.kimi.com/kimi-code/install.sh | bash", + credentialSource: "Signed in through `kimi login`; stored in its config.toml. ADE does not write that file.", + setup: "Install Kimi Code and run `kimi login` in a terminal. Use `--region global` for kimi.ai or `--region mainland-cn` for kimi.com. ADE reuses ~/.kimi-code and does not configure Kimi for you. On Windows the binary needs Git for Windows, because Git Bash is its shell.", + // Stated plainly rather than hidden behind a tooltip: the usage meter is + // simply absent in Kimi chats, and a user who is not told why will read + // that as a bug in ADE. + degradation: "Kimi does not report token usage; the usage meter stays hidden.", + }, + { + ...ACP_PROVIDER_METADATA.grok, + id: "grok", + tagline: "Uses your grok login, or XAI_API_KEY.", + logoFamily: "xai", + installCommand: "npm install -g @xai-official/grok", + // Grok honours no config-home override: it reads ~/.grok and nothing else, + // so ADE reuses whatever is already there and sets nothing. + credentialSource: "Signed in through `grok login` (~/.grok/auth.json), or XAI_API_KEY. ADE does not relocate ~/.grok.", + setup: "Install the Grok CLI and run `grok login`, or set XAI_API_KEY. ADE reuses ~/.grok and does not write Grok's config. Permission cards in ADE chats are the ones ADE can honour; Grok's own defaultMode is not the source of truth.", + }, + { + ...ACP_PROVIDER_METADATA.copilot, + id: "copilot", + tagline: "Uses your GitHub account through the copilot CLI.", + logoFamily: "github-copilot", + installCommand: "npm install -g @github/copilot", + credentialSource: "Signed in through `copilot login`; the free plan includes the CLI. ADE does not write ~/.copilot.", + setup: "Install the Copilot CLI and run `copilot login`. ADE reuses that GitHub login and never writes Copilot's config.json. Cancelled turns can still look finished on Copilot's side; ADE marks them stopped.", + }, +]; + +function specFor(id: AcpSettingsProviderId): AcpProviderSpec { + const found = ACP_PROVIDER_SPECS.find((spec) => spec.id === id); + if (!found) throw new Error(`Unknown ACP provider ${id}`); + return found; +} + +/** + * Status for one ACP provider. + * + * Read from the same `providerConnections` entry every other CLI provider uses, + * so the vocabulary matches: the auth detector proves presence and a credential + * artifact, and `acpAuthProbe` promotes or demotes that through the runtime + * health channel the connection status already folds in. + */ +function acpStatus(ctx: ProvidersViewContext, id: AcpSettingsProviderId): ProviderStatusView { + const spec = specFor(id); + if (statusProbeFailed(ctx)) { + return { + state: "attention", + label: "Needs attention", + message: `Could not load ${spec.label} status.`, + errorLine: ctx.statusLoadError, + }; + } + if (ctx.isInitialCheckInFlight) { + return { + state: "checking", + label: "Checking…", + message: `Checking whether ${spec.label} is installed and signed in.`, + }; + } + const connection = ctx.status?.providerConnections?.[id] ?? null; + if (!connection) { + // The host answered without an entry for this provider, which means it is + // running a build that predates it — not that the CLI is missing. + return { + state: "not-installed", + label: "Not installed", + message: `This machine did not report ${spec.label} status.`, + }; + } + if (connection.runtimeAvailable) { + return { state: "connected", label: "Connected", message: "Connection verified." }; + } + if (connection.runtimeDetected) { + return { + state: "sign-in", + label: "Sign in required", + message: connection.blocker + ?? `${spec.label} is installed but no login was detected. Run \`${spec.loginCommand}\`.`, + }; + } + return { + state: "not-installed", + label: "Not installed", + message: `The ${spec.label} CLI is not on this machine. Install it with \`${spec.installCommand}\`, then check again.`, + errorLine: connection.blocker ?? null, + }; +} + +/** + * Models for one ACP provider. + * + * Curated rows and anything a live session discovered arrive together from the + * registry, in the order the picker uses, with the provider's default first. + * The status payload's copy is preferred when the host sent one, because it has + * already been filtered by what this machine can actually reach. + */ +function acpModels(ctx: ProvidersViewContext, id: AcpSettingsProviderId): ProviderModelRow[] { + const fromStatus = ctx.status?.models?.[id] ?? null; + const registry = listModelDescriptorsForProvider(id); + const defaultId = registry[0]?.id ?? null; + if (fromStatus?.length) { + return fromStatus.map((model) => ({ + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + isDefault: model.id === defaultId, + })); + } + return registry.map((descriptor) => ({ + id: descriptor.id, + label: descriptor.displayName, + isDefault: descriptor.id === defaultId, + })); +} + +function acpFacts(ctx: ProvidersViewContext, id: AcpSettingsProviderId): ProviderFact[] { + const spec = specFor(id); + const diagnostics = ctx.acpDiagnostics[id] ?? null; + const connectionPath = ctx.status?.providerConnections?.[id]?.path ?? null; + const binary = diagnostics?.binaryPath ?? connectionPath; + const configHome = diagnostics?.configHome ?? null; + return [ + ...(binary ? [{ label: "Binary", value: binary, mono: true }] : []), + ...(configHome + ? [{ + label: spec.configHomeEnv ? `Config home (${spec.configHomeEnv})` : "Config home", + value: configHome, + mono: true, + }] + : []), + { label: "Credentials", value: spec.credentialSource }, + { label: "Setup", value: spec.setup }, + ...(spec.degradation ? [{ label: "Known limitation", value: spec.degradation }] : []), + ]; +} + +/** + * Version, only where a version was actually read. + * + * `--version` is a spawn, so it is not on the status path: it arrives with the + * detail page's diagnostics load. Until then there is nothing to render, and + * rendering a guess would be worse than rendering nothing. + */ +function acpVersion(ctx: ProvidersViewContext, id: AcpSettingsProviderId): string | null { + return ctx.acpDiagnostics[id]?.version ?? null; +} + +/** + * The tile's one-line credential for an ACP provider. + * + * The detector proves exactly one thing about these four: whether the CLI wrote + * a credential file (`~/.grok/auth.json` and friends). It cannot tell a + * subscription from an API key the way `providerConnections[].sources` can for + * the CLI providers, so this says only what was actually established — a + * connection that works with no credential file on disk (an env key) gets an + * empty slot rather than an invented "API key". + */ +function acpCredentialLine(ctx: ProvidersViewContext, id: AcpSettingsProviderId): string | null { + if (ctx.isInitialCheckInFlight) return null; + return ctx.status?.providerConnections?.[id]?.authAvailable ? "Signed in" : null; +} + +function AcpAuthActions({ ctx, id }: { ctx: ProvidersViewContext; id: AcpSettingsProviderId }) { + const spec = specFor(id); + const status = acpStatus(ctx, id); + if (status.state === "checking" || status.state === "connected") return null; + if (status.state === "not-installed") { + return ( +
+
+ Install it, then check again: +
+ +
+ ); + } + return ( +
+ +
+ Opens a terminal here and runs {spec.loginCommand}. + It closes itself once you are signed in. +
+
+ ); +} + +/** + * The vendor's own `doctor`, for the two CLIs that ship one. + * + * Its output goes into the copyable report alongside status, version, binary + * path, config home, and the last probe error, so one paste answers "what does + * this machine think about this provider". + */ +function AcpDiagnostics({ ctx, id }: { ctx: ProvidersViewContext; id: AcpSettingsProviderId }) { + const spec = specFor(id); + const diagnostics = ctx.acpDiagnostics[id] ?? null; + const error = ctx.acpDiagnosticsError[id] ?? null; + const busy = ctx.acpDoctorBusy === id; + return ( +
+ + {error ? ( +
+ {error} +
+ ) : null} + {diagnostics?.doctor ? ( +
+          {diagnostics.doctor.output}
+        
+ ) : null} +
+ ); +} + +/** Kimi's honest-degradation note, stated where a user would look for it. */ +function KimiBody() { + return ( +
+ Usage +
+ Kimi does not report token usage; the usage meter stays hidden. +
+
+ ); +} + +function buildAcpDescriptor(spec: AcpProviderSpec): ProviderDescriptor { + return { + id: spec.id, + label: spec.label, + tagline: spec.tagline, + logo: (size) => , + // The tier is a property of the models, so it is read from the registry + // rather than restated here — a model graduating out of preview moves this + // chip on its own. + preview: providerTierIsPreview(spec.id), + // All four share one permission vocabulary because they share one host. + permissions: { family: spec.id === "kimi" ? "moonshot" : spec.id === "grok" ? "xai" : spec.id === "copilot" ? "github-copilot" : "qwen", isCliWrapped: true, key: spec.id }, + status: (ctx) => acpStatus(ctx, spec.id), + models: (ctx) => acpModels(ctx, spec.id), + version: (ctx) => acpVersion(ctx, spec.id), + facts: (ctx) => acpFacts(ctx, spec.id), + credentialLine: (ctx) => acpCredentialLine(ctx, spec.id), + AuthActions: ({ ctx }) => , + ...(spec.id === "grok" || spec.id === "kimi" + ? { Diagnostics: ({ ctx }: { ctx: ProvidersViewContext }) => } + : {}), + ...(spec.id === "kimi" ? { Body: KimiBody } : {}), + }; +} + +export const ACP_PROVIDER_DESCRIPTORS: ProviderDescriptor[] = ACP_PROVIDER_SPECS.map(buildAcpDescriptor); + +/** The terminal command that signs a user in to one provider. */ +export function acpLoginCommand(id: string): string | null { + return ACP_PROVIDER_SPECS.find((spec) => spec.id === id)?.loginCommand ?? null; +} + +export function acpProviderLabel(id: string): string | null { + return ACP_PROVIDER_SPECS.find((spec) => spec.id === id)?.label ?? null; +} diff --git a/apps/desktop/src/renderer/components/settings/providers/bodies/CliAuthActions.tsx b/apps/desktop/src/renderer/components/settings/providers/bodies/CliAuthActions.tsx new file mode 100644 index 0000000000..749fc42609 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/bodies/CliAuthActions.tsx @@ -0,0 +1,55 @@ +/** + * Auth for the three CLI providers ADE only observes: Claude, Codex, Droid. + * ADE cannot sign you in to any of them from here, so the honest surface is the + * command to run — except Claude, which can open a real login terminal in Work. + */ +import React from "react"; +import { COLORS, SANS_FONT } from "../../../lanes/laneDesignTokens"; +import { ClaudeLoginPromptButton } from "../../../work/ClaudeLoginPromptButton"; +import { CopyableCommand } from "../providerUi"; +import { cliTool, installHintFor } from "../cliTools"; +import type { ProvidersViewContext } from "../types"; + +export function ClaudeAuthActions({ ctx }: { ctx: ProvidersViewContext }) { + const availability = ctx.status?.availableProviders?.claude ?? null; + if (ctx.isInitialCheckInFlight) return null; + if (availability?.binary.present && !availability.auth.ready) { + return ( +
+ +
+ ); + } + if (!availability?.binary.present) { + return ; + } + return null; +} + +function CliAuthActions({ ctx, cli }: { ctx: ProvidersViewContext; cli: "codex" | "droid" }) { + const tool = cliTool(cli); + const connection = ctx.status?.providerConnections?.[cli] ?? null; + if (ctx.isInitialCheckInFlight || connection?.runtimeAvailable) return null; + const needsInstall = !connection?.runtimeDetected; + return ( +
+
+ {needsInstall ? "Install it, then refresh:" : "Sign in from a terminal, then refresh:"} +
+ +
+ ); +} + +export function CodexAuthActions({ ctx }: { ctx: ProvidersViewContext }) { + return ; +} + +export function DroidAuthActions({ ctx }: { ctx: ProvidersViewContext }) { + return ; +} diff --git a/apps/desktop/src/renderer/components/settings/providers/bodies/CursorBody.tsx b/apps/desktop/src/renderer/components/settings/providers/bodies/CursorBody.tsx new file mode 100644 index 0000000000..da7fb29891 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/bodies/CursorBody.tsx @@ -0,0 +1,198 @@ +/** + * Cursor's two peers: the OAuth sign-in that mints an ADE key, and a key typed + * in by hand. Neither is the "real" one — Cursor users arrive with either. + */ +import React from "react"; +import { CheckCircle, Info, XCircle } from "@phosphor-icons/react"; +import { COLORS, MONO_FONT, SANS_FONT, SECTION_LABEL_STYLE, outlineButton } from "../../../lanes/laneDesignTokens"; +import { ConnectedTag } from "../../providerSectionPrimitives"; +import { CopyableCommand, SourceBadge } from "../providerUi"; +import type { ProvidersViewContext } from "../types"; + +/** Cursor account OAuth (email in the detail page) is not the same as a + * verified ADE Cursor API key. The tile must not say "Sign in required" + * when this is true. */ +export function cursorOauthSignedIn(ctx: ProvidersViewContext): boolean { + const connection = ctx.status?.providerConnections?.cursor ?? null; + return Boolean( + ctx.cursorAuth?.sdkStatus === "logged-in" + || ctx.cursorAuth?.credentialSource === "cursor-oauth" + || connection?.sources.some((entry) => entry.source === "cursor-oauth"), + ); +} + +function cursorKeyState(ctx: ProvidersViewContext) { + const connection = ctx.status?.providerConnections?.cursor ?? null; + const keySource = ctx.apiKeySources.get("cursor") + ?? (ctx.storedProviders.includes("cursor") ? ("store" as const) : undefined); + const verification = ctx.verificationByProvider.cursor; + const isVerifying = ctx.verifyingProvider === "cursor"; + const isVerified = !isVerifying && verification?.ok; + const isInvalid = !isVerifying && verification && !verification.ok; + return { + connection, + keySource, + verification, + isVerifying, + isVerified, + isInvalid, + isEditing: ctx.editingProvider === "cursor", + isKeyConnected: Boolean(isVerified || (!isInvalid && keySource && connection?.runtimeAvailable)), + }; +} + +export function CursorAuthActions({ ctx }: { ctx: ProvidersViewContext }) { + const { connection, isVerifying } = cursorKeyState(ctx); + const signedInEmail = (ctx.cursorAuth?.email ?? connection?.accountEmail)?.trim() || null; + const oauthSignedIn = cursorOauthSignedIn(ctx); + const loginUrl = ctx.cursorLoginUrl ?? ctx.cursorAuth?.loginUrl ?? null; + + return ( +
+
Sign in with Cursor
+
+ Opens a browser and mints a Cursor API key for ADE. Does not copy Cursor IDE cookies. +
+ {oauthSignedIn ? ( +
+
+ + {signedInEmail ? `Signed in as ${signedInEmail}` : "Signed in with Cursor"} +
+ +
+ ) : ( +
+ + {ctx.cursorLoginBusy ? ( + + ) : null} +
+ )} + {loginUrl && ctx.cursorLoginBusy ? ( +
+
+ If a browser did not open, copy this URL: +
+ +
+ ) : null} +
+ ); +} + +export function CursorBody({ ctx }: { ctx: ProvidersViewContext }) { + const { keySource, verification, isVerifying, isEditing, isKeyConnected } = cursorKeyState(ctx); + + return ( +
+
API key
+
CURSOR_API_KEY
+
+
+ {isEditing ? ( + ctx.actions.setEditValue(event.target.value)} + placeholder="crsr_..." + type="password" + disabled={isVerifying} + style={{ width: "100%", background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} + /> + ) : keySource ? ( +
+ + {isVerifying ? ( + + + Verifying... + + ) : isKeyConnected ? ( + + + Connected + + ) : verification ? ( + + {verification.ok ? : } + {verification.ok ? "Verified" : verification.message} + + ) : ( + + {keySource === "env" ? "Loaded from environment" : keySource === "config" ? "Defined in project config" : "Stored locally"} + + )} +
+ ) : ( + No Cursor API key configured + )} +
+
+ {isEditing ? ( + <> + + + + ) : keySource ? ( + <> + {isKeyConnected ? ( + + ) : ( + + )} + {keySource === "store" ? ( + <> + + + + ) : null} + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/providers/bodies/OpenCodeBody.tsx b/apps/desktop/src/renderer/components/settings/providers/bodies/OpenCodeBody.tsx new file mode 100644 index 0000000000..aafca82fa0 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/bodies/OpenCodeBody.tsx @@ -0,0 +1,382 @@ +/** + * OpenCode's own page, reparented. + * + * OpenCode is one provider on the grid but ~40 model sources behind it, so its + * detail page carries the sub-provider catalog, the local model servers, and + * the advanced custom-provider escape hatch. Sub-providers open in a dialog: + * they are inside OpenCode, not peers of it. + */ +import React from "react"; +import { ArrowsClockwise, Cpu } from "@phosphor-icons/react"; +import { + COLORS, + MONO_FONT, + SANS_FONT, + SECTION_LABEL_STYLE, + outlineButton, + primaryButton, +} from "../../../lanes/laneDesignTokens"; +import { ProviderLogo } from "../../../shared/ProviderLogos"; +import { + getLocalModelIdTail, + getLocalProviderDefaultEndpoint, + getModelById, + LOCAL_PROVIDER_LABELS, + parseLocalProviderFromModelId, +} from "../../../../../shared/modelRegistry"; +import { + ConnectedTag, + ProviderGrid, + ProviderSearchField, + ProviderTile, + ProviderTileBadge, +} from "../../providerSectionPrimitives"; +import type { OpenCodeProviderDetail } from "../../OpenCodeProviderDetailModal"; +import { CopyableCommand } from "../providerUi"; +import { openCodeInstallCommands } from "../cliTools"; +import type { ProvidersViewContext } from "../types"; + +const CUSTOM_PROVIDER_NPM_OPTIONS = [ + "@ai-sdk/openai-compatible", + "@ai-sdk/openai", + "@ai-sdk/anthropic", +]; + +function formatLocalModelLabel(modelId: string): string { + const descriptor = getModelById(modelId); + if (descriptor) return descriptor.displayName; + const provider = parseLocalProviderFromModelId(modelId); + if (provider) { + const tail = getLocalModelIdTail(modelId, provider); + const brand = LOCAL_PROVIDER_LABELS[provider]; + return tail.length ? `${tail} (${brand})` : String(modelId ?? "").trim(); + } + return String(modelId ?? "").trim(); +} + +function OpenCodeProviderCard({ + provider, + onOpen, +}: { + provider: OpenCodeProviderDetail; + onOpen: () => void; +}) { + const badge = provider.connected + ? "Connected" + : provider.hasKey + ? "Key" + : provider.methods.some((m) => m.type === "oauth") + ? "OAuth" + : "Add"; + return ( + : {badge}} + onOpen={onOpen} + footer={typeof provider.modelCount === "number" ? ( +
+ {provider.modelCount} model{provider.modelCount === 1 ? "" : "s"} +
+ ) : undefined} + /> + ); +} + +function LocalModelServers({ ctx }: { ctx: ProvidersViewContext }) { + return ( +
+
+
Local Model Servers
+ +
+
+ {ctx.localRuntimes.map((entry) => { + const isEditing = ctx.editingLocalProvider === entry.provider; + const isSaving = ctx.savingLocalProvider === entry.provider; + const draft = ctx.localProviderDrafts[entry.provider]; + const hasReadyRuntime = entry.runtimeAvailable || (entry.detected && entry.hasModels); + const needsModelLoad = !hasReadyRuntime && !entry.hasModels && (entry.health === "reachable" || entry.health === "reachable_no_models"); + const tone = hasReadyRuntime + ? { color: COLORS.success, label: entry.hasModels ? "Ready" : "Connected" } + : needsModelLoad + ? { color: COLORS.warning, label: "Load a model" } + : entry.blocker + ? { color: COLORS.warning, label: "Blocked" } + : { color: COLORS.warning, label: "Not detected" }; + const loadedModels = entry.modelIds.slice(0, 4); + const extraModelCount = Math.max(0, entry.modelIds.length - loadedModels.length); + const message = entry.blocker + ? entry.blocker + : entry.detected + ? entry.hasModels + ? `${entry.label} is reachable at ${entry.endpoint}. ADE can use ${entry.modelIds.length} loaded model${entry.modelIds.length === 1 ? "" : "s"} from this runtime${entry.health ? ` (${entry.health})` : ""}.` + : `${entry.label} responded, but no loaded models were reported yet. Load a model in ${entry.label} and refresh.` + : `${entry.label} was not detected. Start it, load at least one model, then refresh so ADE can discover its OpenAI-compatible server.`; + + return ( +
+
+
+ +
+
{entry.label}
+
{entry.description}
+
+
+
+ {tone.label} +
+
+ +
{message}
+ + + {draft?.endpoint?.trim() || entry.endpoint} + + +
+ {loadedModels.length > 0 ? ( + <> + {loadedModels.map((modelId) => ( + + + {formatLocalModelLabel(modelId)} + + ))} + {extraModelCount > 0 ? ( + + +{extraModelCount} more + + ) : null} + + ) : ( + No loaded models reported yet. + )} +
+ + {isEditing && draft ? ( +
+ + + + +
+ ) : null} + +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ ); + })} +
+
+ ); +} + +function AdvancedOpenCode({ ctx }: { ctx: ProvidersViewContext }) { + return ( +
+ + Advanced — custom providers & model slugs + +
+
+
Custom provider
+
+ ctx.actions.setCustomProviderDraft((d) => ({ ...d, id: e.target.value }))} placeholder="provider-id" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> + ctx.actions.setCustomProviderDraft((d) => ({ ...d, name: e.target.value }))} placeholder="Display name" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: SANS_FONT, color: COLORS.textPrimary, outline: "none" }} /> + ctx.actions.setCustomProviderDraft((d) => ({ ...d, baseUrl: e.target.value }))} placeholder="https://api.example.com/v1" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> + + ctx.actions.setCustomProviderDraft((d) => ({ ...d, slugs: e.target.value }))} placeholder="model-a, model-b" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> + ctx.actions.setCustomProviderDraft((d) => ({ ...d, apiKey: e.target.value }))} placeholder="API key (optional)" type="password" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> +
+
+ +
+
+ +
+
Custom model slugs
+ ctx.actions.setCustomModelSlugs(e.target.value)} placeholder="provider/model-a, provider/model-b" style={{ background: COLORS.cardBg, border: `1px solid ${COLORS.border}`, padding: "8px 10px", fontSize: 11, fontFamily: MONO_FONT, color: COLORS.textPrimary, outline: "none" }} /> +
+ +
+
+
+
+ ); +} + +export function OpenCodeBody({ ctx }: { ctx: ProvidersViewContext }) { + const statusKnown = ctx.status !== null; + const statusLoadFailed = !statusKnown && !ctx.loading && ctx.statusLoadError !== null; + const installed = ctx.status?.opencodeBinaryInstalled !== false; + const providersStale = ctx.status?.opencodeProvidersStale === true; + + if (!statusKnown && statusLoadFailed) { + return ( +
+ {/* The status line in the left rail already says what went wrong. */} + +
+ ); + } + + if (!statusKnown) { + return ( +
+ Checking OpenCode and its provider catalog… +
+ ); + } + + if (!installed) { + return ( +
+
+ OpenCode powers every subscription, API key, and local model below. Install it, then re-check: +
+
+ {openCodeInstallCommands().map((cmd) => ( + + ))} +
+
+ +
+
+ ); + } + + return ( +
+
+ {providersStale ? ( + + Updating provider catalog… + + ) : null} + +
+ +
+
Connected
+ {ctx.connectedOpenCodeProviders.length === 0 ? ( +
+ No providers connected yet. Pick one below to sign in or add a key. +
+ ) : ( + + {ctx.connectedOpenCodeProviders.map((row) => ( + ctx.actions.openOpenCodeProviderDetail(row.id)} /> + ))} + + )} +
+ +
+
+
All providers · {ctx.openCodeCatalog.length}
+ +
+ + {!ctx.providerSearch.trim() ? ( + <> +
Popular
+ + {ctx.popularOpenCodeProviders.map((row) => ( + ctx.actions.openOpenCodeProviderDetail(row.id)} /> + ))} + + + ) : ctx.searchableOpenCodeProviders.length === 0 ? ( +
+ No providers match your search. +
+ ) : ( + + {ctx.searchableOpenCodeProviders.map((row) => ( + ctx.actions.openOpenCodeProviderDetail(row.id)} /> + ))} + + )} +
+ + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/providers/bodies/PiBody.tsx b/apps/desktop/src/renderer/components/settings/providers/bodies/PiBody.tsx new file mode 100644 index 0000000000..f10443fe82 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/bodies/PiBody.tsx @@ -0,0 +1,78 @@ +/** + * Pi's own page, reparented. + * + * `PiProvidersPanel` is Pi's provider catalog and in-app login flow; it is not + * rewritten here, only given a home. The nested `ProviderDetailDialog` it opens + * for one of Pi's ~40 sub-providers stays a dialog — that is a provider inside + * a provider, not a peer of Claude. + */ +import React from "react"; +import { COLORS, MONO_FONT, SANS_FONT, outlineButton } from "../../../lanes/laneDesignTokens"; +import { openExternalUrl } from "../../../../lib/openExternal"; +import { PiProvidersPanel } from "../../PiProvidersPanel"; +import type { ProvidersViewContext } from "../types"; + +export function PiBody({ ctx }: { ctx: ProvidersViewContext }) { + const piInstallation = ctx.status?.piInstallation ?? null; + const statusLoadFailed = ctx.isInitialCheckInFlight && !ctx.loading && ctx.statusLoadError !== null; + + if (statusLoadFailed) { + return ( + + ); + } + + if (!piInstallation) return null; + + const openPath = (path: string) => { + void window.ade.app.openPath(path).catch((reason: unknown) => { + ctx.actions.setError(reason instanceof Error ? reason.message : String(reason)); + }); + }; + + return ( +
+ {piInstallation.error ? ( +
+ Inventory fallback: {piInstallation.error} +
+ ) : null} + + void ctx.actions.refreshStatus({ force: true })} + onRefreshStatus={() => void ctx.actions.refreshStatus({ force: true })} + /> + +
+ {!piInstallation.sdkAvailable ? ( + + ) : null} + {piInstallation.settingsFileDetected ? ( + + ) : ( + settings.json not found + )} + {piInstallation.authFileDetected ? ( + + ) : ( + auth.json not found + )} + {piInstallation.modelsFileDetected ? ( + + ) : ( + models.json not found + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/providers/cliTools.ts b/apps/desktop/src/renderer/components/settings/providers/cliTools.ts new file mode 100644 index 0000000000..2de23876ff --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/cliTools.ts @@ -0,0 +1,189 @@ +/** + * What each CLI-backed provider is called, how it authenticates, and how it is + * installed — including the Windows install path, which is a different command + * for every vendor and used to be missing entirely. + */ +import type { AiClaudeAvailability, AiProviderConnectionStatus } from "../../../../shared/types"; +import { rendererPlatformAttribute } from "../../../lib/platform"; + +export type CliName = "claude" | "codex" | "cursor" | "droid"; + +// Factory ships a native Windows build of `droid` with its own installer and +// its own way of setting an environment variable — a POSIX `export` line and a +// bare docs link leave a Windows user with nothing to run. +// https://docs.factory.ai/cli/getting-started/quickstart +const DROID_INSTALL_HINT = rendererPlatformAttribute() === "win32" + ? "irm https://app.factory.ai/cli/windows | iex — installs droid.exe into %USERPROFILE%\\bin and puts it on PATH" + : "curl -fsSL https://app.factory.ai/cli | sh — ensure `droid` is on PATH"; +const DROID_LOGIN_CMD = rendererPlatformAttribute() === "win32" + ? "setx FACTORY_API_KEY … (or sign in via `droid` interactive login)" + : "export FACTORY_API_KEY=… (or sign in via `droid` interactive login)"; + +export type CliTool = { + cli: CliName; + label: string; + authStory: string; + loginCmd: string; + installHint: string; + /** Used instead of installHint on Windows, where the vendor ships a different installer. */ + windowsInstallHint?: string; +}; + +export const CLI_TOOLS: CliTool[] = [ + { + cli: "claude", + label: "Claude Code", + authStory: "Uses your claude login — Claude Pro/Max subscription or ANTHROPIC_API_KEY.", + loginCmd: "claude auth login or set ANTHROPIC_API_KEY", + installHint: "npm install -g @anthropic-ai/claude-code", + // Anthropic's documented Windows installs: the PowerShell native installer + // (drops claude.exe in %USERPROFILE%\.localin) or WinGet. + windowsInstallHint: "irm https://claude.ai/install.ps1 | iex (PowerShell), or winget install Anthropic.ClaudeCode", + }, + { + cli: "codex", + label: "Codex CLI", + authStory: "Uses your ChatGPT sign-in — Plus/Pro subscription or OPENAI_API_KEY.", + loginCmd: "codex login", + installHint: "npm install -g @openai/codex", + }, + { + cli: "cursor", + label: "Cursor", + authStory: "Sign in with Cursor or use a Cursor API key.", + loginCmd: "Sign in with Cursor or add a Cursor API key", + installHint: "Get a Cursor API key from https://cursor.com/dashboard/api", + }, + { + cli: "droid", + label: "Droid", + authStory: "Uses your Factory login or FACTORY_API_KEY.", + loginCmd: DROID_LOGIN_CMD, + installHint: DROID_INSTALL_HINT, + }, +]; + +export function cliTool(cli: CliName): CliTool { + const found = CLI_TOOLS.find((tool) => tool.cli === cli); + if (!found) throw new Error(`Unknown CLI tool ${cli}`); + return found; +} + +const isWindowsRenderer = rendererPlatformAttribute() === "win32"; + +export function installHintFor(tool: CliTool): string { + return (isWindowsRenderer && tool.windowsInstallHint) || tool.installHint; +} + +export function buildCliMessage( + tool: CliTool, + connection: AiProviderConnectionStatus | null | undefined, +): string { + if (connection?.runtimeAvailable) { + return "Connection verified."; + } + if (connection?.blocker) { + return connection.blocker; + } + if (connection?.runtimeDetected && !connection.authAvailable) { + return `CLI detected but not signed in. Run: ${tool.loginCmd}`; + } + if (connection?.authAvailable && !connection.runtimeDetected) { + return `Local credentials exist but CLI not found in PATH. Install: ${installHintFor(tool)}`; + } + const pathAdvice = isWindowsRenderer + ? "If already installed, add its folder to your Windows PATH (System Properties -> Environment Variables), reopen ADE, and use Refresh." + : "If already installed, ensure it is on your shell PATH and use Refresh."; + return `CLI not found in PATH. Install: ${installHintFor(tool)}. ${pathAdvice}`; +} + +export function buildClaudeAvailabilityMessage( + availability: AiClaudeAvailability | null | undefined, +): string { + if (!availability?.binary.present) { + return "Claude unavailable (binary missing; should not happen with bundled install; run /doctor)."; + } + if (!availability.auth.ready) { + return availability.auth.detail || "Sign in to use Claude"; + } + return "Ready"; +} + +export function describeCredentialSource( + connection: AiProviderConnectionStatus | null | undefined, +): string | null { + const localSource = connection?.sources.find((entry) => entry.kind === "local-credentials" && entry.detected); + if (!localSource?.source) return null; + if (localSource.source === "macos-keychain") return "Local credentials found in macOS Keychain."; + if (localSource.source === "claude-credentials-file") return "Local credentials found in ~/.claude/.credentials.json."; + if (localSource.source === "codex-auth-file") return "Local credentials found in ~/.codex/auth.json."; + if (localSource.source === "cursor-env") return "Detected via CURSOR_API_KEY environment variable."; + if (localSource.source === "cursor-api-key-store") return "Cursor API key is stored in ADE encrypted storage."; + if (localSource.source === "cursor-oauth") { + const email = connection?.accountEmail?.trim(); + return email ? `Signed in as ${email}.` : "Signed in with Cursor."; + } + if (localSource.source === "factory-env") return "Detected via FACTORY_API_KEY environment variable."; + if (localSource.source === "pi-auth-file") return "Detected via ~/.pi/agent/auth.json."; + if (localSource.source === "pi-models-file") return "Detected via ~/.pi/agent/models.json."; + return null; +} + +/** + * The same credential, in two or three words. + * + * `describeCredentialSource` writes a sentence for the detail page's left rail; + * a tile has one short line and no room for "Local credentials found in + * ~/.claude/.credentials.json." Both read the one `local-credentials` source + * the auth detector reports, so they can never disagree about which credential + * is in play — only about how much of it there is room to say. + */ +export function shortCredentialSource( + connection: AiProviderConnectionStatus | null | undefined, +): string | null { + const localSource = connection?.sources.find((entry) => entry.kind === "local-credentials" && entry.detected); + switch (localSource?.source) { + // The keychain and the credential files are where the vendor CLIs park the + // token a Pro/Max/Plus sign-in minted — a subscription, not a key the user + // pasted. + case "macos-keychain": + case "claude-credentials-file": + case "codex-auth-file": + return "CLI subscription"; + case "cursor-oauth": + return "OAuth"; + case "cursor-admin-env": + case "cursor-env": + case "cursor-api-key-store": + case "factory-env": + return "API key"; + case "pi-auth-file": + case "pi-models-file": + return "Signed in"; + default: + return null; + } +} + +/** + * OpenCode's own documented install methods, per platform. Windows has neither + * Homebrew nor a POSIX shell to pipe the install script into, so it gets the + * package managers OpenCode actually documents for Windows (npm, Scoop, + * Chocolatey) instead of commands that cannot run there. + */ +export function openCodeInstallCommands( + platform: ReturnType = rendererPlatformAttribute(), +): string[] { + if (platform === "win32") { + return [ + "npm i -g opencode-ai", + "scoop install opencode", + "choco install opencode", + ]; + } + return [ + "brew install anomalyco/tap/opencode", + "npm i -g opencode-ai", + "curl -fsSL https://opencode.ai/install | bash", + ]; +} diff --git a/apps/desktop/src/renderer/components/settings/providers/descriptors.tsx b/apps/desktop/src/renderer/components/settings/providers/descriptors.tsx new file mode 100644 index 0000000000..3806b5b329 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/descriptors.tsx @@ -0,0 +1,343 @@ +/** + * The six providers ADE ships today, as descriptors. + * + * Everything provider-specific lives here or in `bodies/`. The grid, the tile, + * and the detail page below read nothing but this table, which is what lets a + * seventh provider be an entry rather than another four hundred lines of JSX. + */ +import React from "react"; +import { ClaudeLogo, CodexLogo, CursorAgentLogo, OpenCodeLogo } from "../../terminals/ToolLogos"; +import { PiLogo, ProviderLogo } from "../../shared/ProviderLogos"; +import { cursorProviderAvailable } from "../../../lib/platform"; +import { buildPiMessage } from "../PiProvidersPanel"; +import { + buildClaudeAvailabilityMessage, + buildCliMessage, + cliTool, + describeCredentialSource, + shortCredentialSource, +} from "./cliTools"; +import { ClaudeAuthActions, CodexAuthActions, DroidAuthActions } from "./bodies/CliAuthActions"; +import { CursorAuthActions, CursorBody, cursorOauthSignedIn } from "./bodies/CursorBody"; +import { PiBody } from "./bodies/PiBody"; +import { OpenCodeBody } from "./bodies/OpenCodeBody"; +import { ACP_PROVIDER_DESCRIPTORS } from "./acpProviders"; +import type { + ProviderDescriptor, + ProviderFact, + ProviderModelRow, + ProviderStatusView, + ProvidersViewContext, + SettingsProviderId, +} from "./types"; +import { statusProbeFailed } from "./types"; + +// The six words a tile is allowed to say — sentence case, no ellipsis-free +// exceptions. "Verification failed" and "Unavailable" used to leak out of two +// descriptors, which made the grid's vocabulary seven and eight words long. +const CHECKING: ProviderStatusView = { + state: "checking", + label: "Checking…", + message: "Checking availability and login status.", +}; + +function pathFacts(path: string | null | undefined, label = "Path"): ProviderFact[] { + return path ? [{ label, value: path, mono: true }] : []; +} + +function credentialFacts(ctx: ProvidersViewContext, cli: "claude" | "codex" | "cursor" | "droid"): ProviderFact[] { + const connection = ctx.status?.providerConnections?.[cli] ?? null; + const description = describeCredentialSource(connection); + if (!description || connection?.runtimeAvailable || ctx.isInitialCheckInFlight) return []; + return [{ label: "Credentials", value: description }]; +} + +/** The tile's one-line credential, read from the same source as the fact row. */ +function credentialLine(ctx: ProvidersViewContext, cli: "claude" | "codex" | "cursor" | "droid" | "pi"): string | null { + if (ctx.isInitialCheckInFlight) return null; + return shortCredentialSource(ctx.status?.providerConnections?.[cli] ?? null); +} + +function descriptorModels(rows: Array<{ id: string; label: string; description?: string; default?: boolean }>): ProviderModelRow[] { + return rows.map((row) => ({ + id: row.id, + label: row.label, + description: row.description, + isDefault: row.default === true, + })); +} + +function probeFailedView(ctx: ProvidersViewContext, who: string): ProviderStatusView { + return { + state: "attention", + label: "Needs attention", + message: `Could not load ${who} status.`, + errorLine: ctx.statusLoadError, + }; +} + +/** Status for the three providers ADE only detects on PATH. */ +function cliStatus(ctx: ProvidersViewContext, cli: "codex" | "droid" | "cursor"): ProviderStatusView { + if (statusProbeFailed(ctx)) return probeFailedView(ctx, cliTool(cli).label); + if (ctx.isInitialCheckInFlight) return CHECKING; + const tool = cliTool(cli); + const connection = ctx.status?.providerConnections?.[cli] ?? null; + const message = buildCliMessage(tool, connection); + if (connection?.runtimeAvailable) { + return { state: "connected", label: "Connected", message }; + } + if (connection?.runtimeDetected || connection?.authAvailable) { + return { state: "sign-in", label: "Sign in required", message }; + } + return { state: "not-installed", label: "Not installed", message, errorLine: connection?.blocker ?? null }; +} + +export const PROVIDER_DESCRIPTORS: ProviderDescriptor[] = [ + { + id: "claude", + label: "Claude Code", + tagline: cliTool("claude").authStory, + logo: (size) => , + permissions: { family: "anthropic", isCliWrapped: true, key: "claude" }, + status: (ctx) => { + if (statusProbeFailed(ctx)) return probeFailedView(ctx, "Claude Code"); + if (ctx.isInitialCheckInFlight) { + return { ...CHECKING, message: "Checking Claude SDK binary and login status." }; + } + const availability = ctx.status?.availableProviders?.claude ?? null; + const message = buildClaudeAvailabilityMessage(availability); + if (availability?.binary.present && availability.auth.ready) { + return { state: "connected", label: "Connected", message }; + } + if (availability?.binary.present) { + return { state: "sign-in", label: "Sign in required", message }; + } + return { state: "not-installed", label: "Not installed", message, errorLine: message }; + }, + models: (ctx) => descriptorModels(ctx.status?.models?.claude ?? []), + facts: (ctx) => [ + ...pathFacts(ctx.status?.availableProviders?.claude?.binary.path ?? ctx.status?.providerConnections?.claude?.path, "Binary"), + ...credentialFacts(ctx, "claude"), + ], + credentialLine: (ctx) => credentialLine(ctx, "claude"), + AuthActions: ClaudeAuthActions, + }, + { + id: "codex", + label: "Codex CLI", + tagline: cliTool("codex").authStory, + logo: (size) => , + permissions: { family: "openai", isCliWrapped: true, key: "codex" }, + status: (ctx) => cliStatus(ctx, "codex"), + models: (ctx) => descriptorModels(ctx.status?.models?.codex ?? []), + facts: (ctx) => [ + ...pathFacts(ctx.status?.providerConnections?.codex?.path, "Binary"), + ...credentialFacts(ctx, "codex"), + ], + credentialLine: (ctx) => credentialLine(ctx, "codex"), + AuthActions: CodexAuthActions, + }, + { + id: "cursor", + label: "Cursor", + tagline: cliTool("cursor").authStory, + logo: (size) => , + // Hidden entirely on Windows on ARM: @cursor/sdk has no win32-arm64 build, + // so the card could only ever offer a provider that cannot start. + // See shared/providerPlatformSupport.ts. + isAvailable: () => cursorProviderAvailable(), + permissions: { family: "cursor", isCliWrapped: true, key: "cursor" }, + status: (ctx) => { + if (statusProbeFailed(ctx)) return probeFailedView(ctx, "Cursor"); + const verification = ctx.verificationByProvider.cursor; + if (ctx.verifyingProvider === "cursor") { + return { state: "checking", label: "Checking…", message: "Verifying Cursor API key with the Cursor SDK." }; + } + if (verification?.ok) { + return { + state: "connected", + label: "Connected", + message: "Cursor SDK connected. ADE uses this key for Cursor chat and Cursor Cloud agents.", + }; + } + if (verification && !verification.ok) { + return { + state: "attention", + label: "Needs attention", + message: verification.message, + errorLine: verification.message, + }; + } + if (ctx.isInitialCheckInFlight) { + return { ...CHECKING, message: "Checking Cursor SDK API key." }; + } + const base = cliStatus(ctx, "cursor"); + const message = ctx.status?.providerConnections?.cursor?.blocker + ?? (base.state === "connected" ? base.message : "Sign in with Cursor or enter a Cursor API key."); + // OAuth can show an email on the detail page while ADE still needs a + // verified Cursor API key for chat. Do not label that "Sign in required". + if (base.state === "sign-in" && cursorOauthSignedIn(ctx)) { + return { state: "attention", label: "Needs attention", message }; + } + return { ...base, message }; + }, + models: (ctx) => descriptorModels(ctx.status?.models?.cursor ?? []), + facts: (ctx) => [ + ...pathFacts(ctx.status?.providerConnections?.cursor?.path, "SDK"), + ...credentialFacts(ctx, "cursor"), + ], + credentialLine: (ctx) => credentialLine(ctx, "cursor"), + AuthActions: CursorAuthActions, + Body: CursorBody, + }, + { + id: "droid", + label: "Droid", + tagline: cliTool("droid").authStory, + logo: (size) => , + permissions: { family: "factory", isCliWrapped: true, key: "droid" }, + status: (ctx) => cliStatus(ctx, "droid"), + models: (ctx) => descriptorModels(ctx.status?.models?.droid ?? []), + facts: (ctx) => [ + ...pathFacts(ctx.status?.providerConnections?.droid?.path, "Binary"), + ...credentialFacts(ctx, "droid"), + ], + credentialLine: (ctx) => credentialLine(ctx, "droid"), + AuthActions: DroidAuthActions, + }, + { + id: "pi", + label: "Pi", + tagline: "Uses Pi’s installed SDK package and redacted auth status from its native profile.", + logo: (size) => , + permissions: { family: "pi", isCliWrapped: false, key: "pi" }, + status: (ctx) => { + const installation = ctx.status?.piInstallation ?? null; + const connection = ctx.status?.providerConnections?.pi ?? null; + const loadFailed = ctx.isInitialCheckInFlight && !ctx.loading && ctx.statusLoadError !== null; + if (loadFailed) { + return { + state: "attention", + label: "Needs attention", + message: `Could not load Pi status: ${ctx.statusLoadError}`, + errorLine: ctx.statusLoadError, + }; + } + if (ctx.isInitialCheckInFlight) { + return { ...CHECKING, message: "Checking Pi installation and provider inventory." }; + } + const message = buildPiMessage(connection, installation); + const errorLine = installation?.error ? `Inventory fallback: ${installation.error}` : null; + if (connection?.runtimeAvailable) { + return { state: "connected", label: "Connected", message, errorLine }; + } + if (!installation?.installed && !connection?.runtimeDetected) { + return { state: "not-installed", label: "Not installed", message, errorLine }; + } + if (installation?.installed && !installation.sdkAvailable) { + return { state: "attention", label: "Needs attention", message, errorLine: errorLine ?? message }; + } + return { state: "sign-in", label: "Sign in required", message, errorLine }; + }, + models: (ctx) => (ctx.status?.piInstallation?.availableModelIds ?? []).map((id) => ({ id, label: id })), + version: (ctx) => { + const installation = ctx.status?.piInstallation ?? null; + if (!installation?.version) return null; + return installation.stale ? `${installation.version} · cached` : installation.version; + }, + facts: (ctx) => pathFacts(ctx.status?.providerConnections?.pi?.path, "Path"), + credentialLine: (ctx) => credentialLine(ctx, "pi"), + Body: PiBody, + }, + { + id: "opencode", + label: "OpenCode", + tagline: "SuperGrok OAuth, ChatGPT, Copilot, or API keys — the same providers OpenCode connects.", + logo: (size) => , + permissions: { family: "opencode", isCliWrapped: true, key: "opencode" }, + status: (ctx) => { + const known = ctx.status !== null; + if (!known) { + const loadFailed = !ctx.loading && ctx.statusLoadError !== null; + if (loadFailed) { + return { + state: "attention", + label: "Needs attention", + message: "Could not load OpenCode status.", + errorLine: ctx.statusLoadError, + }; + } + return { ...CHECKING, message: "Checking OpenCode and its provider catalog…" }; + } + if (ctx.status?.opencodeBinaryInstalled === false) { + return { + state: "not-installed", + label: "Not installed", + message: "OpenCode powers every subscription, API key, and local model. Install it, then re-check.", + }; + } + if (ctx.status?.opencodeInventoryError) { + return { + state: "attention", + label: "Needs attention", + message: "OpenCode is installed, but its provider catalog could not be read.", + errorLine: ctx.status.opencodeInventoryError, + }; + } + const connected = ctx.connectedOpenCodeProviders.length; + return { + state: "connected", + label: "Connected", + message: connected === 0 + ? "OpenCode is installed. No model providers are connected yet." + : `OpenCode is installed with ${connected} connected provider${connected === 1 ? "" : "s"}.`, + }; + }, + models: (ctx) => (ctx.status?.availableModelIds ?? []).map((id) => ({ id, label: String(id) })), + facts: (ctx) => { + const source = ctx.status?.opencodeBinarySource; + return source ? [{ label: "Binary", value: source }] : []; + }, + // OpenCode holds no credential of its own — every one belongs to a + // sub-provider inside it, and naming one of forty on the tile would be + // arbitrary. The count of connected providers is already in the message. + credentialLine: () => null, + Body: OpenCodeBody, + }, + // The four ACP providers. They are built from one shared table rather than + // written out here, because everything that differs between them is data. + ...ACP_PROVIDER_DESCRIPTORS, +]; + +const BY_ID = new Map(PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.id, descriptor] as const)); + +/** + * The status a tile and a page should show. + * + * "Disabled" outranks everything a probe could say: whether the CLI is present + * or signed in is not the question once the user has switched the provider off, + * and reporting "Connected" for a provider that offers no models would be a + * lie. Read through this rather than calling `descriptor.status` directly. + */ +export function providerStatusFor( + descriptor: ProviderDescriptor, + ctx: ProvidersViewContext, +): ProviderStatusView { + if (ctx.disabledProviders.has(descriptor.id)) { + return { + state: "disabled", + label: "Disabled", + message: `${descriptor.label} is switched off. Its models do not appear in any picker on this machine.`, + }; + } + return descriptor.status(ctx); +} + +export function providerDescriptor(id: string): ProviderDescriptor | null { + return BY_ID.get(id as SettingsProviderId) ?? null; +} + +/** Descriptors this platform can actually run. */ +export function availableProviderDescriptors(): ProviderDescriptor[] { + return PROVIDER_DESCRIPTORS.filter((descriptor) => descriptor.isAvailable?.() !== false); +} diff --git a/apps/desktop/src/renderer/components/settings/providers/providerDiagnosticsReport.ts b/apps/desktop/src/renderer/components/settings/providers/providerDiagnosticsReport.ts new file mode 100644 index 0000000000..5cd67bf1d3 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/providerDiagnosticsReport.ts @@ -0,0 +1,47 @@ +/** + * The text behind "Copy diagnostics" on a provider page. + * + * One paste has to answer "what does this machine think about this provider", + * so it carries the status word and message, the version, every left-rail fact + * (binary path, config home, credential source), the last auth probe error, and + * — where the vendor ships one — the output of its own `doctor`. + * + * Absent facts are named rather than omitted: a missing line reads as "not + * checked", which is a different claim from "not found". + */ + +import type { AcpProviderDiagnostics } from "../../../../shared/types/config"; +import type { ProviderFact, ProviderStatusView } from "./types"; + +export function formatProviderDiagnosticsReport(args: { + label: string; + status: ProviderStatusView; + version: string | null; + facts: ProviderFact[]; + acp: AcpProviderDiagnostics | null; +}): string { + const lines = [ + `provider: ${args.label}`, + `status: ${args.status.label} — ${args.status.message}`, + `version: ${args.version ?? args.acp?.versionError ?? "unknown"}`, + ]; + for (const fact of args.facts) { + lines.push(`${fact.label.toLowerCase()}: ${fact.value}`); + } + const probeError = args.acp?.lastProbe && args.acp.lastProbe.state !== "ready" + ? `${args.acp.lastProbe.state} — ${args.acp.lastProbe.message ?? "no detail"}` + : args.status.errorLine ?? null; + lines.push(`last error: ${probeError ?? "none"}`); + if (args.acp) { + lines.push(`binary source: ${args.acp.binarySource}`); + lines.push(`checked at: ${args.acp.checkedAt}`); + } + if (args.acp?.doctor) { + lines.push( + "", + `$ ${args.acp.doctor.command} (exit ${args.acp.doctor.exitCode ?? "none"})`, + args.acp.doctor.output, + ); + } + return lines.join("\n"); +} diff --git a/apps/desktop/src/renderer/components/settings/providers/providerUi.test.ts b/apps/desktop/src/renderer/components/settings/providers/providerUi.test.ts new file mode 100644 index 0000000000..a549403dd5 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/providerUi.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { normalizeProviderVersion } from "./providerUi"; + +describe("normalizeProviderVersion", () => { + // The string that shipped to a tile as `vgrok 1.0.13 (5e9a58528b76) [stable]` + // — binary name, commit hash, and channel tag, wider than the tile itself. + it("keeps only the semver core of Grok's --version output", () => { + expect(normalizeProviderVersion("grok 1.0.13 (5e9a58528b76) [stable]")).toBe("1.0.13"); + }); + + it("leaves a bare version alone", () => { + expect(normalizeProviderVersion("0.84.0")).toBe("0.84.0"); + }); + + it("strips a leading v", () => { + expect(normalizeProviderVersion("v1.2.3")).toBe("1.2.3"); + }); + + it("keeps a prerelease suffix", () => { + expect(normalizeProviderVersion("v2.0.0-beta.4")).toBe("2.0.0-beta.4"); + }); + + // Pi marks a version it read from cache rather than from the SDK. That is + // ADE's own annotation, not vendor noise, so it has to survive. + it("preserves an ADE-appended annotation", () => { + expect(normalizeProviderVersion("0.84.0 · cached")).toBe("0.84.0 · cached"); + }); + + it("is idempotent", () => { + const once = normalizeProviderVersion("grok 1.0.13 (5e9a58528b76) [stable]"); + expect(normalizeProviderVersion(once)).toBe(once); + }); + + it("returns null when nothing version-shaped is left", () => { + expect(normalizeProviderVersion("unknown")).toBeNull(); + expect(normalizeProviderVersion("")).toBeNull(); + expect(normalizeProviderVersion(null)).toBeNull(); + expect(normalizeProviderVersion(undefined)).toBeNull(); + // A bare integer is not a version; printing "7" under a provider name + // would be worse than printing nothing. + expect(normalizeProviderVersion("7")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/settings/providers/providerUi.tsx b/apps/desktop/src/renderer/components/settings/providers/providerUi.tsx new file mode 100644 index 0000000000..ea33e2d252 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/providerUi.tsx @@ -0,0 +1,321 @@ +/** Small shared pieces every provider tile, detail page, and body draws from. */ +import React from "react"; +import { CheckCircle, Copy, X, XCircle } from "@phosphor-icons/react"; +import { COLORS, MONO_FONT, SANS_FONT } from "../../lanes/laneDesignTokens"; +import { useCopyToClipboard } from "../../../hooks/useCopyToClipboard"; +import type { ApiKeySource } from "../OpenCodeProviderDetailModal"; +import type { ProviderStatusState } from "./types"; + +export function prettifyProviderId(id: string): string { + return id + .split(/[-_/]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +const STATUS_COLORS: Record = { + checking: COLORS.info, + connected: COLORS.success, + "sign-in": COLORS.warning, + attention: COLORS.danger, + "not-installed": COLORS.textDim, + disabled: COLORS.textDim, +}; + +export function providerStatusColor(state: ProviderStatusState): string { + return STATUS_COLORS[state]; +} + +/** + * Dot + sentence-case word. The only status vocabulary on this page. + * + * Deliberately NOT an uppercase letterspaced chip: at "Sign in required" that + * treatment was the widest thing in a tile header and it pushed provider names + * ("GitHub Copilot") into an ellipsis. A dot and a normal sentence carry the + * same state in roughly half the width, and read as English rather than as a + * console banner. + */ +export function ProviderStatusChip({ + state, + label, +}: { + state: ProviderStatusState; + label: string; +}) { + const color = providerStatusColor(state); + return ( +
+ + + {label} + +
+ ); +} + +/** + * The version core a provider page and tile should print. + * + * Vendors do not agree on what `--version` means: Pi answers `0.84.0`, Grok + * answers `grok 1.0.13 (5e9a58528b76) [stable]`, and the tile used to bolt a + * `v` onto whatever arrived — which is how a tile ended up rendering + * `vgrok 1.0.13 (5e9a58528b76) [stable]`, wider than the tile itself. The only + * part a user acts on is the dotted number, so that is what is kept: the first + * semver-shaped token, with the binary name, the leading `v`, the commit hash, + * and the channel tag dropped. + * + * A ` · ` annotation ADE itself appended (Pi's `· cached`) survives, because it + * is ADE's claim about the number rather than vendor noise around it. + */ +export function normalizeProviderVersion(raw: string | null | undefined): string | null { + if (typeof raw !== "string") return null; + const [head = "", ...notes] = raw.split("·").map((part) => part.trim()); + const core = /\d+(?:\.\d+)+(?:[-+][0-9A-Za-z.]+)?/.exec(head)?.[0] ?? null; + if (!core) return null; + const kept = notes.filter(Boolean); + return kept.length > 0 ? `${core} · ${kept.join(" · ")}` : core; +} + +export function AlertBanner({ + tone, + message, + onDismiss, +}: { + tone: "success" | "error" | "warning"; + message: string; + onDismiss: () => void; +}) { + const color = tone === "success" ? COLORS.success : tone === "warning" ? COLORS.warning : COLORS.danger; + const token = tone === "success" ? "success" : tone === "warning" ? "warning" : "error"; + return ( +
+ {message} + +
+ ); +} + +const SOURCE_BADGE_MAP: Record = { + store: { color: COLORS.success, label: "Local store" }, + env: { color: COLORS.info, label: "Environment" }, + config: { color: COLORS.warning, label: "Project config" }, +}; + +export function SourceBadge({ source }: { source: ApiKeySource }) { + const { color, label } = SOURCE_BADGE_MAP[source]; + return ( + + {label} + + ); +} + +export function CopyableCommand({ command }: { command: string }) { + const { copy, copied } = useCopyToClipboard(); + return ( + + ); +} + +/** + * Copy a block of diagnostic text. + * + * Distinct from `CopyableCommand`: that renders what it copies, which is right + * for a one-line install command and wrong for a twenty-line report. + */ +export function CopyReportButton({ report, label }: { report: string; label: string }) { + const { copy, copied } = useCopyToClipboard(); + return ( + + ); +} + +/** A filesystem path or a command. The only place mono type is load-bearing. */ +export function PathLine({ value }: { value: string }) { + return ( + + {value} + + ); +} + +/** The Settings-only tier label. Pickers never show it. */ +export function PreviewChip() { + return ( + + Preview + + ); +} + +export function SubsectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** The one-line error a failed enumerate renders as. */ +export function ProviderErrorRow({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/providers/types.ts b/apps/desktop/src/renderer/components/settings/providers/types.ts new file mode 100644 index 0000000000..01baaf0a93 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/providers/types.ts @@ -0,0 +1,286 @@ +/** + * The descriptor contract behind Settings → Agents & Models. + * + * Every provider used to be its own hand-written block of JSX inside + * `ProvidersSection`, which is why the six had six different ideas of what a + * status word means and why adding a seventh meant copying four hundred lines. + * A provider is now a *descriptor*: what it is called, how to read its status + * out of the one status payload, what facts its tile shows, and — only for the + * genuinely bespoke flows — a body component for its detail page. + * + * Adding a provider is adding a descriptor. Nothing else in this folder should + * need to know a provider's name. + */ + +import type React from "react"; +import type { + AcpProviderDiagnostics, + AiApiKeyVerificationResult, + AiSettingsStatus, + CursorSdkAuthStatus, + ProjectConfigSnapshot, +} from "../../../../shared/types"; +import type { AgentChatPermissionMode } from "../../../../shared/types"; +import type { + AiProviderPermissions, + OpenCodeProviderAuthMethods, +} from "../../../../shared/types/config"; +import type { LocalProviderFamily } from "../../../../shared/modelRegistry"; +import type { AcpProviderId } from "../../../../shared/acpProviderMetadata"; +import type { ApiKeySource, OpenCodeProviderDetail } from "../OpenCodeProviderDetailModal"; + +/** Every provider with a tile and a page. Same ids as `ModelProviderGroup`. */ +export type SettingsProviderId = + | "claude" + | "codex" + | "cursor" + | "droid" + | "pi" + | "opencode" + | "qwen" + | "kimi" + | "grok" + | "copilot"; + +/** The four providers ADE drives over the Agent Client Protocol. */ +export type AcpSettingsProviderId = AcpProviderId; + +/** + * The six words a tile is allowed to say. + * + * `checking` is first-class and distinct from `not-installed`: a status probe + * that has not answered yet is not the same claim as "this is not on your + * machine", and presenting the first as the second is how a slow first probe + * used to tell people to reinstall a working CLI. + */ +export type ProviderStatusState = + | "checking" + | "connected" + | "sign-in" + | "attention" + | "not-installed" + | "disabled"; + +export type ProviderStatusView = { + state: ProviderStatusState; + /** The one word (or two) next to the dot. */ + label: string; + /** The sentence under the name on the detail page. */ + message: string; + /** + * The raw enumerate/probe failure, if there is one. The model list IS the + * health check, so this is the only error surface — there is no Verify. + */ + errorLine?: string | null; +}; + +export type ProviderFact = { + label: string; + value: string; + /** Paths and commands only. */ + mono?: boolean; +}; + +/** Everything a descriptor, tile, or body can read or do. */ +export type ProvidersViewContext = { + status: AiSettingsStatus | null; + projectConfigSnapshot: ProjectConfigSnapshot | null; + loading: boolean; + statusLoadError: string | null; + /** True until the first status payload lands — the "Checking…" gate. */ + isInitialCheckInFlight: boolean; + + storedProviders: string[]; + apiKeySources: Map; + hasKeyFor: (providerId: string) => boolean; + verificationByProvider: Record; + verifyingProvider: string | null; + editingProvider: string | null; + editValue: string; + + cursorAuth: CursorSdkAuthStatus | null; + cursorLoginBusy: boolean; + cursorLoginUrl: string | null; + + authMethods: OpenCodeProviderAuthMethods | null; + authMethodsError: string | null; + openCodeCatalog: OpenCodeProviderDetail[]; + connectedOpenCodeProviders: OpenCodeProviderDetail[]; + popularOpenCodeProviders: OpenCodeProviderDetail[]; + searchableOpenCodeProviders: OpenCodeProviderDetail[]; + providerSearch: string; + refreshingCatalog: boolean; + + localRuntimes: LocalRuntimeRow[]; + localProviderDrafts: Record; + editingLocalProvider: LocalProviderFamily | null; + savingLocalProvider: LocalProviderFamily | null; + + customProviderDraft: CustomProviderDraft; + customModelSlugs: string; + savingAdvanced: boolean; + + /** Providers switched off in `ai.disabledProviders`. */ + disabledProviders: ReadonlySet; + savingDisabledFor: SettingsProviderId | null; + + /** + * Per-provider CLI facts, loaded when a detail page opens. + * + * Absent means "not asked yet", which is why this is a partial record rather + * than a record of nullable values: the page must be able to tell "no version + * reported" from "we have not looked". + */ + acpDiagnostics: Partial>; + acpDiagnosticsBusy: AcpSettingsProviderId | null; + acpDoctorBusy: AcpSettingsProviderId | null; + acpDiagnosticsError: Partial>; + + /** Abstract permission defaults as persisted in `ai.permissions.providers`. */ + permissionDefaults: AiProviderPermissions; + savingPermissionFor: SettingsProviderId | null; + defaultModelId: string | null; + savingDefaultModel: boolean; + + actions: ProvidersActions; +}; + +export type ProvidersActions = { + refreshStatus: (options?: { + force?: boolean; + silent?: boolean; + refreshOpenCodeInventory?: boolean; + }) => Promise; + loadAuthMethods: () => Promise; + setError: (message: string | null) => void; + setNotice: (message: string | null) => void; + + beginEditing: (provider: string) => void; + cancelEditing: () => void; + setEditValue: (value: string) => void; + deleteApiKey: (provider: string, options?: { alsoOpenCode?: boolean }) => Promise; + verifyApiKey: (provider: string) => Promise; + + saveCursorApiKey: () => Promise; + loginWithCursor: () => Promise; + logoutCursor: () => Promise; + cancelCursorLogin: () => Promise; + + setProviderSearch: (value: string) => void; + refreshCatalog: () => Promise; + openOpenCodeProviderDetail: (id: string) => void; + + updateLocalProviderDraft: (provider: LocalProviderFamily, patch: Partial) => void; + beginEditingLocalRuntime: (provider: LocalProviderFamily) => void; + cancelEditingLocalRuntime: () => void; + saveLocalProvider: (provider: LocalProviderFamily) => Promise; + + setCustomProviderDraft: React.Dispatch>; + setCustomModelSlugs: (value: string) => void; + saveAdvancedProvider: () => Promise; + saveCustomModelSlugs: () => Promise; + + setPermissionDefault: (provider: SettingsProviderId, mode: AgentChatPermissionMode) => Promise; + setDefaultModel: (modelId: string | null) => Promise; + + revealClaudeLoginTerminal: (terminal: { terminalId: string; laneId: string }) => void; + + /** Flip a provider off (or back on). Writes the whole `disabledProviders` list. */ + setProviderDisabled: (provider: SettingsProviderId, disabled: boolean) => Promise; + /** Read binary path, config home, version, and last probe verdict. Spawns. */ + loadAcpDiagnostics: (provider: AcpSettingsProviderId) => Promise; + /** Run the vendor's own `doctor` and fold the output into the diagnostics. */ + runAcpDoctor: (provider: AcpSettingsProviderId) => Promise; + /** Open the embedded terminal that runs this provider's login command. */ + openSignInTerminal: (provider: SettingsProviderId) => void; +}; + +/** + * The first status probe finished without a payload. Tiles must not stay on + * Checking… after that — that reads as a hang, not a failed load. + */ +export function statusProbeFailed( + ctx: Pick, +): boolean { + return ctx.isInitialCheckInFlight && !ctx.loading && ctx.statusLoadError != null; +} + +export type LocalProviderDraft = { + enabled: boolean; + endpoint: string; + autoDetect: boolean; + preferredModelId: string; +}; + +export type LocalRuntimeRow = { + provider: LocalProviderFamily; + label: string; + description: string; + endpoint: string; + health: string | null; + blocker: string | null; + runtimeAvailable: boolean; + detected: { type: "local"; provider: LocalProviderFamily; endpoint: string } | null; + modelIds: string[]; + hasModels: boolean; +}; + +export type CustomProviderDraft = { + id: string; + name: string; + baseUrl: string; + npm: string; + slugs: string; + apiKey: string; +}; + +/** A model row on a detail page. */ +export type ProviderModelRow = { + id: string; + label: string; + /** The provider's own curated default, marked with a star. */ + isDefault?: boolean; + description?: string; +}; + +export type ProviderDescriptor = { + id: SettingsProviderId; + label: string; + /** One plain line under the name. */ + tagline: string; + logo: (size: number) => React.ReactNode; + /** Settings-only tier label. Unused by the six; the ACP previews will set it. */ + preview?: boolean; + /** Platforms where the provider cannot run at all hide the whole card. */ + isAvailable?: () => boolean; + + /** Which `getPermissionOptions` table this provider's abstract modes come from. */ + permissions: { family: string; isCliWrapped: boolean; key: keyof AiProviderPermissions }; + + status: (ctx: ProvidersViewContext) => ProviderStatusView; + /** Models ADE can offer for this provider right now. */ + models: (ctx: ProvidersViewContext) => ProviderModelRow[]; + /** Version string when the provider reports one. No update-available surface. */ + version?: (ctx: ProvidersViewContext) => string | null; + /** Left-rail identity rows (binary path, config files, credential source). */ + facts?: (ctx: ProvidersViewContext) => ProviderFact[]; + /** + * Two or three words for where a working provider's credential came from — + * "CLI subscription", "API key", "OAuth", "Signed in". + * + * This is what a connected tile shows in the slot a broken tile uses for its + * error, so the grid stays one shape. Null when the payload does not actually + * say; an empty slot is honest and a guess is not. + */ + credentialLine?: (ctx: ProvidersViewContext) => string | null; + + /** + * Extra rows under Troubleshooting — the vendor `doctor` button, for the two + * providers that ship one. + */ + Diagnostics?: React.ComponentType<{ ctx: ProvidersViewContext }>; + /** Sign in / sign out / key entry. Rendered in the left rail. */ + AuthActions?: React.ComponentType<{ ctx: ProvidersViewContext }>; + /** The bespoke flow — Pi's catalog, OpenCode's catalog, Cursor's key field. */ + Body?: React.ComponentType<{ ctx: ProvidersViewContext }>; +}; diff --git a/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts b/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts index acb31c74d1..1e5bf1db78 100644 --- a/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts +++ b/apps/desktop/src/renderer/components/settings/settingsManifest.test.ts @@ -183,6 +183,58 @@ describe("settings manifest", () => { expect(new Set(groups).size).toBe(groups.length); }); + // Settings → Agents & Models is a grid of providers with a page each, and the + // manifest is what makes those pages reachable from ⌘K and from a link. + it("gives every shipped provider its own reachable entry", () => { + for (const provider of [ + "claude", "codex", "cursor", "droid", "pi", "opencode", + "qwen", "kimi", "grok", "copilot", + ]) { + const entry = SETTINGS_ENTRIES.find((candidate) => candidate.id === `agents.provider.${provider}`); + expect(entry, `no manifest entry for ${provider}`).toBeDefined(); + expect(entry!.anchor).toBe(`ai-provider-${provider}`); + expect(entry!.tab).toBe("agents"); + expect(resolveSettingsHash(entry!.anchor)?.id).toBe(entry!.id); + expect(settingsRouteFor(entry!.id)).toBe(`/settings?tab=agents#ai-provider-${provider}`); + } + }); + + it("routes brand names to the provider that serves them", () => { + // None of these words appear in a label, so without keywords a user typing + // the name they know reaches nothing. + const cases: [string, string][] = [ + ["anthropic", "agents.provider.claude"], + ["openai", "agents.provider.codex"], + ["chatgpt", "agents.provider.codex"], + ["factory", "agents.provider.droid"], + // The four ACP brands now own their own pages. A user typing "moonshot" + // must reach Kimi's page, not the OpenCode catalog it used to hide in. + ["moonshot", "agents.provider.kimi"], + ["kimi", "agents.provider.kimi"], + ["grok", "agents.provider.grok"], + ["xai", "agents.provider.grok"], + ["copilot", "agents.provider.copilot"], + ["github copilot", "agents.provider.copilot"], + ["qwen", "agents.provider.qwen"], + // And OpenCode keeps the vendors it is still the only route to. + ["openrouter", "agents.provider.opencode"], + ["deepseek", "agents.provider.opencode"], + ]; + for (const [query, expectedId] of cases) { + expect(searchSettingsEntries(query).map((entry) => entry.id), `"${query}" did not reach ${expectedId}`) + .toContain(expectedId); + } + }); + + it("keeps the pre-redesign providers deeplinks resolving", () => { + // `#ai-providers` and `?tab=ai` / `?tab=providers` shipped in tours, + // banners, and copied URLs long before the per-provider pages existed. + expect(resolveSettingsHash("ai-providers")?.id).toBe("agents.providers"); + expect(resolveSettingsTab("ai")).toBe("agents"); + expect(resolveSettingsTab("providers")).toBe("agents"); + expect(settingsRouteFor("agents.providers")).toBe("/settings?tab=agents#ai-providers"); + }); + it("only marks scope chips on settings whose storage would surprise", () => { // Team-scoped settings always warrant the chip: they are committed and // affect other people. diff --git a/apps/desktop/src/renderer/components/settings/settingsManifest.ts b/apps/desktop/src/renderer/components/settings/settingsManifest.ts index 52dc497861..78406fd17b 100644 --- a/apps/desktop/src/renderer/components/settings/settingsManifest.ts +++ b/apps/desktop/src/renderer/components/settings/settingsManifest.ts @@ -311,6 +311,117 @@ export const SETTINGS_ENTRIES: readonly SettingEntry[] = [ showScopeChip: true, group: "Connections", }, + // One entry per provider, so ⌘K, settings search, and deeplinks land on the + // provider's own page rather than the top of the list. The keywords carry the + // brand names a user actually types — "anthropic", "factory", "xai" — none of + // which appear in the labels. + { + id: "agents.provider.claude", + label: "Claude Code", + keywords: ["anthropic", "claude", "provider", "sign in", "api key", "model", "permission"], + tab: "agents", + anchor: "ai-provider-claude", + scope: "machine", + web: "hidden", + group: "Connections", + }, + { + id: "agents.provider.codex", + label: "Codex CLI", + keywords: ["openai", "chatgpt", "codex", "provider", "sign in", "api key", "model", "permission"], + tab: "agents", + anchor: "ai-provider-codex", + scope: "machine", + web: "hidden", + group: "Connections", + }, + { + id: "agents.provider.cursor", + label: "Cursor", + keywords: ["cursor", "provider", "oauth", "sign in", "api key", "model", "permission"], + tab: "agents", + anchor: "ai-provider-cursor", + scope: "machine", + web: "hidden", + group: "Connections", + }, + { + id: "agents.provider.droid", + label: "Droid", + keywords: ["factory", "droid", "provider", "sign in", "api key", "model", "permission"], + tab: "agents", + anchor: "ai-provider-droid", + scope: "machine", + web: "hidden", + group: "Connections", + }, + { + id: "agents.provider.pi", + label: "Pi", + keywords: ["pi", "earendil", "provider", "sign in", "model", "permission"], + tab: "agents", + anchor: "ai-provider-pi", + scope: "machine", + web: "hidden", + group: "Connections", + }, + { + id: "agents.provider.opencode", + label: "OpenCode", + keywords: [ + "opencode", "provider", "sign in", "api key", "model", "permission", + // The vendors OpenCode is still the only route to. Qwen, Moonshot/Kimi, + // xAI/Grok, and GitHub Copilot moved to their own entries below, where a + // user typing the brand now lands on the provider's own page. + "openrouter", "groq", "together", "deepseek", "mistral", "google", "gemini", + "ollama", "lm studio", + ], + tab: "agents", + anchor: "ai-provider-opencode", + scope: "machine", + web: "hidden", + group: "Connections", + }, + { + id: "agents.provider.qwen", + label: "Qwen Code", + keywords: ["qwen", "alibaba", "qwen code", "acp", "provider", "sign in", "api key", "openai", "base url", "model", "permission"], + tab: "agents", + anchor: "ai-provider-qwen", + scope: "machine", + web: "hidden", + group: "Connections", + }, + { + id: "agents.provider.kimi", + label: "Kimi", + keywords: ["kimi", "moonshot", "moonshotai", "kimi code", "acp", "provider", "sign in", "model", "permission"], + tab: "agents", + anchor: "ai-provider-kimi", + scope: "machine", + web: "hidden", + group: "Connections", + }, + { + id: "agents.provider.grok", + label: "Grok", + keywords: ["grok", "xai", "x.ai", "acp", "provider", "sign in", "api key", "model", "permission"], + tab: "agents", + anchor: "ai-provider-grok", + scope: "machine", + web: "hidden", + group: "Connections", + }, + { + id: "agents.provider.copilot", + label: "GitHub Copilot", + keywords: ["copilot", "github", "github copilot", "acp", "provider", "sign in", "model", "permission"], + tab: "agents", + anchor: "ai-provider-copilot", + scope: "machine", + web: "hidden", + group: "Connections", + }, { id: "agents.background-jobs", label: "Background helpers", diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index 6a141a0f91..8f08c3e907 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -40,6 +40,7 @@ vi.mock("@lobehub/icons", () => { OpenAI: brand(), OpenCode: brand(), OpenRouter: brand(), + Qwen: brand(), XAI: brand(), }; }); @@ -162,6 +163,7 @@ vi.mock("./modelOrdering", () => ({ })); import { composeModelPickerTriggerLabel, ModelPicker } from "./ModelPicker"; +import { cursorProviderAvailable } from "../../../lib/platform"; import { rememberRuntimeCatalog, resetModelPickerRuntimeCatalogForTests, @@ -2001,6 +2003,146 @@ describe("ModelPicker", () => { expect(trigger.getAttribute("aria-expanded")).toBe("false"); }); }); + describe("ACP provider rails", () => { + it("keeps Qwen, Kimi, Grok, and GitHub Copilot in the rail before those models are discovered", async () => { + const user = userEvent.setup(); + authOnlyState = true; + providerAuthStatusInternal = { anthropic: "ok" }; + renderPicker({ models: MODELS }); + await user.click(screen.getByRole("button", { name: /Select model/i })); + + expect(document.querySelector('[data-rail-selection="provider:qwen"]')).toBeTruthy(); + expect(document.querySelector('[data-rail-selection="provider:moonshot"]')).toBeTruthy(); + expect(document.querySelector('[data-rail-selection="provider:xai"]')).toBeTruthy(); + expect(document.querySelector('[data-rail-selection="provider:github-copilot"]')).toBeTruthy(); + expect(screen.getByRole("tab", { name: /^Qwen$/i })).toBeTruthy(); + expect(screen.getByRole("tab", { name: /^Kimi$/i })).toBeTruthy(); + expect(screen.getByRole("tab", { name: /^Grok$/i })).toBeTruthy(); + expect(screen.getByRole("tab", { name: /^GitHub Copilot$/i })).toBeTruthy(); + + const railKeys = Array.from(document.querySelectorAll("[data-rail-selection]")) + .map((entry) => entry.getAttribute("data-rail-selection")); + const expectedRailKeys = [ + "favorites", + "recents", + "provider:anthropic", + "provider:openai", + "provider:cursor", + "provider:opencode", + "provider:pi", + "provider:github-copilot", + "provider:xai", + "provider:factory", + "provider:moonshot", + "provider:qwen", + "provider:ollama", + "provider:lmstudio", + ].filter((key) => key !== "provider:cursor" || cursorProviderAvailable()); + expect(railKeys).toEqual(expectedRailKeys); + }); + + it("lists curated Qwen models when the Qwen rail is selected", async () => { + const user = userEvent.setup(); + renderPicker(); + await user.click(screen.getByRole("button", { name: /Select model/i })); + await user.click(screen.getByRole("tab", { name: /^Qwen$/i })); + expect(await findModelRow("qwen/qwen3-coder-plus")).toBeTruthy(); + }); + + it("lists curated Qwen models in auth-only mode once Qwen is authenticated", async () => { + const user = userEvent.setup(); + authOnlyState = true; + providerAuthStatusInternal = { anthropic: "ok", qwen: "ok" }; + renderPicker({ models: MODELS }); + await user.click(screen.getByRole("button", { name: /Select model/i })); + await user.click(screen.getByRole("tab", { name: /^Qwen$/i })); + expect(await findModelRow("qwen/qwen3-coder-plus")).toBeTruthy(); + }); + + it("replaces curated Qwen rows with the connected provider catalog", async () => { + const user = userEvent.setup(); + const modelCatalog = vi.fn(async (): Promise => ({ + groups: [{ + key: "qwen", + displayName: "Qwen", + providers: [{ + key: "qwen", + displayName: "Qwen", + badgeColor: "#6D4AFF", + modelCount: 1, + subsections: [{ + key: "qwen", + label: "Qwen", + models: [{ + id: "qwen/gpt-5.5", + runtimeModelId: "gpt-5.5", + provider: "qwen", + providerKey: "qwen", + groupKey: "qwen", + displayName: "gpt-5.5", + isDefault: true, + isAvailable: true, + supportsReasoning: false, + supportsTools: true, + }], + }], + }], + }], + fetchedAt: "2026-05-18T00:00:00.000Z", + stale: false, + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { agentChat: { modelCatalog } }, + }); + + renderPicker({ models: [] }); + await user.click(screen.getByRole("button", { name: /Select model/i })); + await user.click(screen.getByRole("tab", { name: /^Qwen$/i })); + + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "qwen" }); + }); + expect(await findModelRow("qwen/gpt-5.5")).toBeTruthy(); + expect(document.querySelector('[data-model-id="qwen/qwen3-coder-plus"]')).toBeNull(); + }); + + it("refreshes the ACP catalog when an ACP rail is selected", async () => { + const user = userEvent.setup(); + const modelCatalog = vi.fn(async () => ({ + groups: [], + fetchedAt: "2026-05-18T00:00:00.000Z", + stale: false, + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { agentChat: { modelCatalog } }, + }); + + renderPicker(); + await user.click(screen.getByRole("button", { name: /Select model/i })); + + await user.click(screen.getByRole("tab", { name: /^Qwen$/i })); + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "qwen" }); + }); + await user.click(screen.getByRole("tab", { name: /^Kimi$/i })); + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "kimi" }); + }); + await user.click(screen.getByRole("tab", { name: /^Grok$/i })); + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "grok" }); + }); + await user.click(screen.getByRole("tab", { name: /^GitHub Copilot$/i })); + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "refresh-stale", refreshProvider: "copilot" }); + }); + }); + }); + // The rail lists Cursor unconditionally so it stays reachable before the // catalog refresh streams in — except on Windows on ARM, where @cursor/sdk has // no build. See apps/desktop/src/shared/providerPlatformSupport.ts. @@ -2026,6 +2168,10 @@ describe("ModelPicker", () => { expect(keys).toContain("provider:anthropic"); expect(keys).toContain("provider:openai"); expect(keys).toContain("provider:factory"); + expect(keys).toContain("provider:qwen"); + expect(keys).toContain("provider:moonshot"); + expect(keys).toContain("provider:xai"); + expect(keys).toContain("provider:github-copilot"); expect(keys).toContain("provider:opencode"); }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index 20920cc0c5..37bdde4019 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -14,6 +14,7 @@ import type { AuthStatus } from "./ModelPickerRail"; import { createUnknownModelPlaceholder, descriptorsFromAgentChatModelCatalog, + filterAcpFallbackModelsToRuntimeCatalog, mergeSelectorModels, resolveModelDescriptorWithRuntimeCatalog, } from "./modelCatalog"; @@ -290,12 +291,15 @@ export const ModelPicker = memo(function ModelPicker({ if (!normalizedValue) return ""; return constrainedAvailable.has(normalizedValue) ? normalizedValue : ""; })(); - const fallbackModels = mergeSelectorModels( - availableModelIds, - selectedValue, - filter, - constrainToAvailableModelIds ? "available-only" : catalogMode, - catalogScopeKey, + const fallbackModels = filterAcpFallbackModelsToRuntimeCatalog( + mergeSelectorModels( + availableModelIds, + selectedValue, + filter, + constrainToAvailableModelIds ? "available-only" : catalogMode, + catalogScopeKey, + ), + catalogModels.models, ); if (catalogModels.models.length === 0) return fallbackModels; if (constrainToAvailableModelIds) return fallbackModels; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index a9dfecee75..ab8ed11ede 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -19,6 +19,10 @@ import { type ModelDescriptor, type ProviderFamily, } from "../../../../shared/modelRegistry"; +import { + MODEL_PICKER_PROVIDER_ORDER, + type ProviderGroupKey, +} from "../../../../shared/modelCatalog"; import { cn } from "../../ui/cn"; import { cursorProviderAvailable } from "../../../lib/platform"; import { ModelListRow } from "./ModelListRow"; @@ -32,7 +36,10 @@ import { useProviderAuthStatus } from "./useProviderAuthStatus"; import { scoreModelPickerSearch } from "./modelPickerSearch"; import { sortModelItems } from "./modelOrdering"; import { ProviderSetupBanner } from "./providerEmptyState"; -import type { RuntimeCatalogModelDescriptor } from "./modelCatalog"; +import { + filterAcpFallbackModelsToRuntimeCatalog, + type RuntimeCatalogModelDescriptor, +} from "./modelCatalog"; import type { AgentChatModelCatalogRefreshProvider, OpenProjectBinding } from "../../../../shared/types"; import { refreshProviderForFamily } from "./runtimeCatalogCache"; @@ -45,7 +52,7 @@ const PROVIDER_LABELS: Partial> = { google: "Google", mistral: "Mistral", deepseek: "DeepSeek", - xai: "xAI", + xai: "Grok", groq: "Groq", together: "Together", openrouter: "OpenRouter", @@ -54,21 +61,34 @@ const PROVIDER_LABELS: Partial> = { cursor: "Cursor", factory: "Droid", pi: "Pi", + qwen: "Qwen", + moonshot: "Kimi", + "github-copilot": "GitHub Copilot", }; // Order matters for rail layout — top-tier providers first, then routers, // then local runtimes. Listed here (not derived from PROVIDER_LABELS) because // PROVIDER_LABELS may include experimental entries we don't want surfaced. -const ALL_PROVIDER_FAMILIES: readonly ProviderFamily[] = [ - "anthropic", - "openai", - "factory", - "pi", - "cursor", - "opencode", - "ollama", - "lmstudio", -]; +// ACP CLI families (Qwen / Kimi / Grok / Copilot) are first-class rails so +// they stay reachable before catalog refresh, same as Cursor and Droid. +const PICKER_FAMILY_BY_GROUP: Record = { + claude: "anthropic", + codex: "openai", + cursor: "cursor", + opencode: "opencode", + pi: "pi", + copilot: "github-copilot", + grok: "xai", + droid: "factory", + kimi: "moonshot", + qwen: "qwen", + ollama: "ollama", + lmstudio: "lmstudio", +}; + +const ALL_PROVIDER_FAMILIES: readonly ProviderFamily[] = MODEL_PICKER_PROVIDER_ORDER.map( + (groupKey) => PICKER_FAMILY_BY_GROUP[groupKey], +); function providerLabel(family: ProviderFamily | string): string { return PROVIDER_LABELS[family as ProviderFamily] ?? family; @@ -125,7 +145,13 @@ function pickerFamilyForModel(model: ModelDescriptor): ProviderFamily { function providerAuthEstablishesModelAvailability(model: ModelDescriptor): boolean { if (isPiRoutedModel(model)) return true; const provider = resolveCliProviderForModel(model); - return provider === "claude" || provider === "codex" || provider === "droid"; + return provider === "claude" + || provider === "codex" + || provider === "droid" + || provider === "qwen" + || provider === "kimi" + || provider === "grok" + || provider === "copilot"; } // The runtime catalog flags a model as requiring configuration when it is @@ -276,7 +302,11 @@ export const ModelPickerContent = memo(function ModelPickerContent({ } if (!merged.has(m.id)) merged.set(m.id, m); } - return [...merged.values()]; + const runtimeAcpModels = models.filter( + (model): model is RuntimeCatalogModelDescriptor => + (model as RuntimeCatalogModelDescriptor).catalogAvailable === true, + ); + return filterAcpFallbackModelsToRuntimeCatalog([...merged.values()], runtimeAcpModels); }, [allowRegistryExpansion, authOnly, familyIsReady, models, registryFilter]); const providersPresent = useMemo(() => { @@ -292,9 +322,10 @@ export const ModelPickerContent = memo(function ModelPickerContent({ if (pickerFamily === "cursor" && !cursorProviderAvailable()) continue; set.add(pickerFamily); } - // Always include dynamic-only provider families (Cursor, Droid, OpenCode, - // local runtimes). Their models may not exist until a catalog refresh runs, - // but the rail entry must still be reachable without toggling "Show all models". + // Always include first-class provider families (Cursor, Droid, ACP CLIs, + // OpenCode, local runtimes). Their models may not exist until a catalog + // refresh runs, but the rail entry must still be reachable without + // toggling "Show all models". for (const family of families) set.add(family); // Stabilize rail order so it doesn't flicker as catalog discovery streams in. return families.filter((family) => set.has(family)) @@ -428,6 +459,16 @@ export const ModelPickerContent = memo(function ModelPickerContent({ return selection.slice("provider:".length) as ProviderFamily; }, [searchActive, selection]); + // A fresh picker can open directly on a provider selected by the persisted + // draft model. Refresh that provider once on mount so the first list is based + // on the current ACP verdict, not yesterday's curated fallback rows. + const initialProviderRefreshRequestedRef = useRef(false); + useEffect(() => { + if (initialProviderRefreshRequestedRef.current) return; + initialProviderRefreshRequestedRef.current = true; + if (activeProviderFamily) onProviderRailSelect?.(activeProviderFamily); + }, [activeProviderFamily, onProviderRailSelect]); + const activeRefreshProvider = activeProviderFamily ? refreshProviderForFamily(activeProviderFamily) : null; const activeProviderRefreshing = activeRefreshProvider != null && refreshingProvider === activeRefreshProvider; const activeProviderRefreshFailed = activeRefreshProvider != null && refreshErrorProvider === activeRefreshProvider; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx index 60a8051673..04052c9f54 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerRail.tsx @@ -57,7 +57,7 @@ export const ModelPickerRail = memo(function ModelPickerRail({ role="tablist" aria-orientation="vertical" data-model-picker-rail="true" - className="flex w-12 shrink-0 flex-col gap-0.5 border-r border-white/[0.06] bg-black/[0.18] p-1" + className="flex w-12 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-white/[0.06] bg-black/[0.18] p-1" > {entries.map((entry, index) => { const key = entryKey(entry); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx index 4d1f1b3690..22a6599b29 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx @@ -34,6 +34,7 @@ vi.mock("@lobehub/icons", () => { OpenAI: brand(), OpenCode: brand(), OpenRouter: brand(), + Qwen: brand(), XAI: brand(), }; }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts index 1377e9dd00..113ae30156 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { descriptorsFromAgentChatModelCatalog, + filterAcpFallbackModelsToRuntimeCatalog, getRuntimeCatalogModelDescriptor, mergeSelectorModels, resetRuntimeCatalogDescriptorCacheForTests, @@ -16,7 +17,10 @@ import { runtimeCatalogProviderIsFresh, } from "./runtimeCatalogCache"; import type { AgentChatModelCatalog } from "../../../../shared/types"; -import { createDynamicPiModelDescriptor } from "../../../../shared/modelRegistry"; +import { + createDynamicAcpModelDescriptor, + createDynamicPiModelDescriptor, +} from "../../../../shared/modelRegistry"; describe("mergeSelectorModels", () => { beforeEach(() => { @@ -126,6 +130,22 @@ describe("mergeSelectorModels", () => { expect(opencodeModels.length).toBe(0); }); + it("replaces curated ACP rows with the connected provider's live models", () => { + const qwenFallbacks = mergeSelectorModels(undefined, undefined, undefined, "all") + .filter((model) => model.family === "qwen"); + const liveQwen = { + ...createDynamicAcpModelDescriptor("qwen", "gpt-5.5"), + catalogAvailable: true, + }; + + const filtered = filterAcpFallbackModelsToRuntimeCatalog( + [...qwenFallbacks, liveQwen], + [liveQwen], + ); + + expect(filtered.map((model) => model.id)).toEqual(["qwen/gpt-5.5"]); + }); + it("surfaces only the discovered Droid model — no canonical entries are injected", () => { const merged = mergeSelectorModels(["droid/some-custom-model"], undefined, undefined, "all"); const droidModels = merged.filter((m) => m.family === "factory"); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts index e9436a3f99..def6f53a8e 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts @@ -11,6 +11,7 @@ import { type ModelDescriptor, type ProviderFamily, } from "../../../../shared/modelRegistry"; +import type { ProviderGroupKey } from "../../../../shared/modelCatalog"; import type { AgentChatModelCatalog } from "../../../../shared/types"; import { PROVIDER_BADGE_COLORS } from "../providerModelSelectorGrouping"; import { @@ -193,22 +194,87 @@ export type RuntimeCatalogModelDescriptor = ModelDescriptor & { catalogRequiresConfiguration?: boolean; }; -function pickerFamilyForCatalogGroup(groupKey: string, fallbackFamily?: string): ProviderFamily { - if (groupKey === "claude") return "anthropic"; - if (groupKey === "codex") return "openai"; - if (groupKey === "droid") return "factory"; - if (groupKey === "cursor") return "cursor"; - if (groupKey === "opencode") return "opencode"; - if (groupKey === "pi") return "pi"; - if (groupKey === "ollama") return "ollama"; - if (groupKey === "lmstudio") return "lmstudio"; - if (fallbackFamily === "ollama" || fallbackFamily === "lmstudio" || fallbackFamily === "cursor" || fallbackFamily === "factory") { - return fallbackFamily; - } - if (fallbackFamily === "anthropic" || fallbackFamily === "openai" || fallbackFamily === "opencode") { - return fallbackFamily; +const RUNTIME_DISCOVERED_ACP_FAMILIES: readonly ProviderFamily[] = [ + "qwen", + "moonshot", + "xai", + "github-copilot", +]; + +/** + * Remove static ACP fallback rows once a live catalog has reported the models + * that provider can actually reach. Unauthenticated providers keep their + * curated rows (so the picker can still explain how to connect), while a + * connected provider such as Qwen no longer defaults to a stale catalog row. + */ +export function filterAcpFallbackModelsToRuntimeCatalog( + models: readonly ModelDescriptor[], + catalogModels: readonly RuntimeCatalogModelDescriptor[], +): ModelDescriptor[] { + const availableByFamily = new Map>(); + for (const model of catalogModels) { + if ( + model.catalogAvailable !== true + || !RUNTIME_DISCOVERED_ACP_FAMILIES.includes(model.family) + ) { + continue; + } + const family = model.family; + const ids = availableByFamily.get(family) ?? new Set(); + ids.add(model.id); + availableByFamily.set(family, ids); } - return "opencode"; + + if (availableByFamily.size === 0) return [...models]; + return models.filter((model) => { + const available = availableByFamily.get(model.family); + return !available || available.has(model.id); + }); +} + +/** + * Catalog group key -> the provider family the picker rails are keyed on. + * Exhaustive over `ProviderGroupKey`, so a new provider group cannot land here + * as an unlabelled OpenCode row: adding one is a compile error until it names + * its family. + */ +const PICKER_FAMILY_BY_CATALOG_GROUP: Record = { + claude: "anthropic", + codex: "openai", + droid: "factory", + cursor: "cursor", + opencode: "opencode", + pi: "pi", + qwen: "qwen", + kimi: "moonshot", + grok: "xai", + copilot: "github-copilot", + ollama: "ollama", + lmstudio: "lmstudio", +}; + +/** Families a catalog row may claim directly when its group key is unknown. */ +const PICKER_FALLBACK_FAMILIES: readonly ProviderFamily[] = [ + "ollama", + "lmstudio", + "cursor", + "factory", + "anthropic", + "openai", + "opencode", + "qwen", + "moonshot", + "xai", + "github-copilot", +]; + +function pickerFamilyForCatalogGroup(groupKey: string, fallbackFamily?: string): ProviderFamily { + const mapped = Object.hasOwn(PICKER_FAMILY_BY_CATALOG_GROUP, groupKey) + ? PICKER_FAMILY_BY_CATALOG_GROUP[groupKey as ProviderGroupKey] + : undefined; + if (mapped) return mapped; + const claimed = PICKER_FALLBACK_FAMILIES.find((family) => family === fallbackFamily); + return claimed ?? "opencode"; } export function descriptorsFromAgentChatModelCatalog( diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx index d4aa0f1eb7..92b6f95a2c 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx @@ -81,6 +81,42 @@ const PROVIDER_COPY: Partial> = { action: { kind: "open-external", url: "https://ollama.com/download" }, }, }, + qwen: { + title: "Set up Qwen Code", + body: "Install Qwen Code and configure it in that CLI. ADE reuses ~/.qwen and does not write it. Point Qwen at an OpenAI-compatible key (OPENAI_API_KEY, optional OPENAI_BASE_URL for a local proxy).", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Qwen Code docs", + action: { kind: "open-external", url: "https://github.com/QwenLM/qwen-code" }, + }, + }, + moonshot: { + title: "Set up Kimi", + body: "Install Kimi Code and run `kimi login` (`--region global` for kimi.ai, `--region mainland-cn` for kimi.com). ADE reuses ~/.kimi-code and does not write it.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Kimi Code docs", + action: { kind: "open-external", url: "https://github.com/MoonshotAI/kimi-code" }, + }, + }, + xai: { + title: "Set up Grok", + body: "Install the Grok CLI and run `grok login`, or set XAI_API_KEY. ADE reuses ~/.grok and does not relocate it.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Grok CLI docs", + action: { kind: "open-external", url: "https://docs.x.ai/docs/grok-cli" }, + }, + }, + "github-copilot": { + title: "Set up GitHub Copilot", + body: "Install the Copilot CLI and run `copilot login`. ADE reuses that login and does not write ~/.copilot.", + primary: { label: "Open Settings", action: { kind: "open-settings" } }, + secondary: { + label: "Copilot CLI docs", + action: { kind: "open-external", url: "https://github.com/github/copilot-cli" }, + }, + }, pi: { title: "Connect Pi", body: "Use Pi’s native /login flow or configure a provider in your Pi profile. ADE keeps Pi credentials in Pi’s own profile.", @@ -118,6 +154,10 @@ const PROVIDER_DISPLAY_LABELS: Partial> = { cursor: "Cursor", factory: "Droid", pi: "Pi", + qwen: "Qwen", + moonshot: "Kimi", + xai: "Grok", + "github-copilot": "GitHub Copilot", opencode: "OpenCode", lmstudio: "LM Studio", ollama: "Ollama", diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts index 04b48740de..8e28850454 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts @@ -1,14 +1,21 @@ import type { AgentChatModelCatalog, AgentChatModelCatalogRefreshProvider } from "../../../../shared/types"; import type { ModelDescriptor, ProviderFamily } from "../../../../shared/modelRegistry"; +const REFRESH_PROVIDER_BY_FAMILY: Partial> = { + opencode: "opencode", + ollama: "ollama", + lmstudio: "lmstudio", + cursor: "cursor", + pi: "pi", + factory: "droid", + qwen: "qwen", + moonshot: "kimi", + xai: "grok", + "github-copilot": "copilot", +}; + export function refreshProviderForFamily(family: ProviderFamily): AgentChatModelCatalogRefreshProvider | null { - if (family === "opencode") return "opencode"; - if (family === "ollama") return "ollama"; - if (family === "lmstudio") return "lmstudio"; - if (family === "cursor") return "cursor"; - if (family === "pi") return "pi"; - if (family === "factory") return "droid"; - return null; + return REFRESH_PROVIDER_BY_FAMILY[family] ?? null; } const RUNTIME_CATALOG_REFRESH_TTL_MS = 30 * 60_000; @@ -20,6 +27,10 @@ const REFRESH_PROVIDERS: AgentChatModelCatalogRefreshProvider[] = [ "droid", "lmstudio", "ollama", + "qwen", + "kimi", + "grok", + "copilot", ]; /** diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts index 7f599ae72a..fb28181a50 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.test.ts @@ -23,6 +23,30 @@ import { // actually authenticated. Prior regression: hook checked a nonexistent // `runtimeAvailable` field and dimmed every Claude row even for working users. describe("familiesFromStatus", () => { + it("greys an ACP provider ADE has probed but not signed in, and leaves an unprobed one absent", () => { + const out = familiesFromStatus({ + availableProviders: { claude: false, codex: false, cursor: false, droid: false, qwen: true }, + providerConnections: { + qwen: { authAvailable: true, runtimeAvailable: true }, + grok: { authAvailable: false, runtimeAvailable: false }, + }, + }); + expect(out.qwen).toBe("ok"); + expect(out.xai).toBe("unauthed"); + // Never probed: absent, not signed out. An absent rail is "checking", + // which is a different thing from "sign in". + expect(out.moonshot).toBeUndefined(); + expect(out["github-copilot"]).toBeUndefined(); + }); + + it("marks an ACP provider ok from runtimeAvailable alone", () => { + const out = familiesFromStatus({ + availableProviders: { claude: false, codex: false, cursor: false, droid: false }, + providerConnections: { copilot: { authAvailable: true, runtimeAvailable: true } }, + }); + expect(out["github-copilot"]).toBe("ok"); + }); + it("marks Claude as ok when auth.ready is true (no runtimeAvailable field)", () => { const out = familiesFromStatus({ availableProviders: { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts index 4f2729532d..8d837731ab 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/useProviderAuthStatus.ts @@ -15,9 +15,26 @@ import type { AuthStatus } from "./ModelPickerRail"; type AuthStatusMap = Partial>; +type ProviderConnectionFlags = { authAvailable?: boolean; runtimeAvailable?: boolean }; + type ProviderStatusSnapshot = { - availableProviders?: { claude?: unknown; codex?: unknown; cursor?: unknown; droid?: unknown }; - providerConnections?: { pi?: { authAvailable?: boolean; runtimeAvailable?: boolean } }; + availableProviders?: { + claude?: unknown; + codex?: unknown; + cursor?: unknown; + droid?: unknown; + qwen?: unknown; + kimi?: unknown; + grok?: unknown; + copilot?: unknown; + }; + providerConnections?: { + pi?: ProviderConnectionFlags; + qwen?: ProviderConnectionFlags; + kimi?: ProviderConnectionFlags; + grok?: ProviderConnectionFlags; + copilot?: ProviderConnectionFlags; + }; piInstallation?: { sdkAvailable?: boolean; cliAvailable?: boolean; availableModelIds?: string[] }; opencodeProviders?: Array<{ id: string; connected: boolean }>; opencodeBinaryInstalled?: unknown; @@ -28,6 +45,17 @@ type ProviderBinarySnapshot = { binaryProbed: boolean; }; +/** + * ACP provider -> picker rail family. One table, so the greying arms and the + * empty-state copy cannot name different families for the same provider. + */ +const ACP_PICKER_FAMILIES = [ + { provider: "qwen", family: "qwen" }, + { provider: "kimi", family: "moonshot" }, + { provider: "grok", family: "xai" }, + { provider: "copilot", family: "github-copilot" }, +] as const satisfies readonly { provider: string; family: ProviderFamily }[]; + const EMPTY_AUTH_STATUS: AuthStatusMap = {}; const UNKNOWN_BINARY: ProviderBinarySnapshot = { opencodeBinaryInstalled: false, @@ -92,6 +120,20 @@ export function familiesFromStatus( out.pi = "unauthed"; } + // ACP providers. Each one is CLI-backed, so "ok" means ADE found both the + // binary and a credential for it; the host resolves that into + // `availableProviders` and `providerConnections`. A provider ADE has not + // probed yet stays absent from the map rather than reading as signed out. + for (const acp of ACP_PICKER_FAMILIES) { + const connection = status.providerConnections?.[acp.provider]; + const flagged = status.availableProviders?.[acp.provider] === true; + if (flagged || connection?.runtimeAvailable === true) { + out[acp.family] = "ok"; + } else if (connection) { + out[acp.family] = "unauthed"; + } + } + return out; } diff --git a/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx b/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx index 51b6ba7e8e..efc24e63fe 100644 --- a/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx +++ b/apps/desktop/src/renderer/components/shared/ProviderLogos.tsx @@ -15,6 +15,7 @@ import { OpenAI, OpenCode, OpenRouter, + Qwen, XAI, } from "@lobehub/icons"; import { @@ -193,6 +194,8 @@ export function ProviderLogo({ return ; case "lmstudio": return ; + case "qwen": + return ; case "moonshotai": case "moonshot": case "kimi": @@ -200,6 +203,7 @@ export function ProviderLogo({ return ; case "github-copilot": case "githubcopilot": + case "copilot": return ; case "github": case "github-models": diff --git a/apps/desktop/src/renderer/components/shared/permissionOptions.ts b/apps/desktop/src/renderer/components/shared/permissionOptions.ts index 65a0eb6b37..e591ca36e1 100644 --- a/apps/desktop/src/renderer/components/shared/permissionOptions.ts +++ b/apps/desktop/src/renderer/components/shared/permissionOptions.ts @@ -25,6 +25,21 @@ function normalizePermissionFamily(family: string): string { return family; } +/** + * Model families ADE drives over the Agent Client Protocol. + * + * They share one permission vocabulary because they share one host: the + * permission round-trip is `session/request_permission` in every dialect, and + * ADE writes an abstract posture that each dialect maps to its own flags. So + * they get one permission key and one option list, not four. + */ +const ACP_PERMISSION_FAMILIES: ReadonlySet = new Set([ + "qwen", + "moonshot", + "xai", + "github-copilot", +]); + export function resolvePersistentIdentityGuardedPermissionMode(opts: { family: string; isCliWrapped: boolean; @@ -319,6 +334,63 @@ export function getPermissionOptions(opts: { ]; } + // ACP providers (Qwen, Kimi, Grok, GitHub Copilot) + // + // One arm for all four. ADE writes an abstract posture and each dialect maps + // it to its own native flags at the launch boundary, so the labels describe + // what the user gets rather than any one vendor's vocabulary. The ladder + // matches `AgentChatAcpPermissionMode`; `edit` and `full-auto` are ADE's + // spellings of that type's `auto-edit` and `yolo`. + if (opts.isCliWrapped && ACP_PERMISSION_FAMILIES.has(normalizePermissionFamily(opts.family))) { + return [ + { + value: "plan", + label: "Plan", + shortDesc: "Read-only — no file edits or commands", + detail: "The agent reads, searches, and proposes a plan. Nothing it suggests runs until you switch modes.", + allows: ["File reads", "Code search", "Plan generation"], + blocks: ["File writes & edits", "Shell commands"], + safety: "safe", + }, + { + value: "default", + label: "Ask", + shortDesc: "Prompts before each write or command", + detail: "The agent asks permission for every file change and every shell command, one at a time.", + allows: ["File reads", "Code search"], + gates: ["File writes & edits", "Shell commands", "Web access"], + safety: "safe", + }, + { + value: "edit", + label: "Accept edits", + shortDesc: "File edits auto-approved; commands still ask", + detail: "The agent edits files without asking, and still pauses before running shell commands.", + allows: ["File reads", "File writes & edits", "Code search"], + gates: ["Shell commands", "Web access"], + safety: "semi-auto", + }, + { + value: "auto", + label: "Auto", + shortDesc: "The agent judges each request itself", + detail: "The agent decides which actions are safe to take on its own and asks only for the rest.", + allows: ["File reads", "File writes & edits", "Most shell commands"], + gates: ["Actions the agent judges risky"], + safety: "semi-auto", + }, + { + value: "full-auto", + label: "Full access", + shortDesc: "Every request approved without asking", + detail: "Nothing prompts. Where the provider cannot be told to stop asking, ADE approves each request for you and records it in the transcript.", + allows: ["Everything"], + warning: "⚠ Only use in isolated or disposable working copies.", + safety: "danger", + }, + ]; + } + // API and local models return [ { @@ -407,33 +479,40 @@ export function safetyColors(safety: SafetyLevel) { } } +export type PermissionFamilyKey = "claude" | "codex" | "cursor" | "droid" | "acp" | "opencode"; + /** * Map a ProviderFamily string to the permission-family key used by - * provider permission config ("claude" | "codex" | "cursor" | "droid" | "opencode"). + * provider permission config. * * Only CLI-wrapped anthropic → "claude" and CLI-wrapped openai → "codex". + * The four ACP families collapse to one "acp" key: they share a host, a + * permission round-trip, and an abstract posture, so a per-vendor key would + * duplicate the same setting four times. * All API / local models (even anthropic-api or openai-api) use "opencode". */ export function familyToPermissionKey( family: string, isCliWrapped: boolean, -): "claude" | "codex" | "cursor" | "droid" | "opencode" { +): PermissionFamilyKey { if (isCliWrapped) { if (family === "anthropic") return "claude"; if (family === "openai") return "codex"; if (family === "cursor") return "cursor"; if (family === "factory") return "droid"; + if (ACP_PERMISSION_FAMILIES.has(family)) return "acp"; } return "opencode"; } /** Human-readable label for a permission family key */ -export function permissionFamilyLabel(key: "claude" | "codex" | "cursor" | "droid" | "opencode"): string { +export function permissionFamilyLabel(key: PermissionFamilyKey): string { switch (key) { case "claude": return "Claude Code workers"; case "codex": return "Codex workers"; case "cursor": return "Cursor workers"; case "droid": return "Droid workers"; + case "acp": return "Qwen, Kimi, Grok and Copilot workers"; case "opencode": return "OpenCode workers"; } } diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx index 6385a7cda1..1b21ec9767 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx @@ -58,6 +58,7 @@ vi.mock("@lobehub/icons", () => { OpenAI: brand(), OpenCode: brand(), OpenRouter: brand(), + Qwen: brand(), XAI: brand(), }; }); diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index 38ed77ef43..a5448fa3ab 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -25,6 +25,7 @@ import { } from "./cliLaunch"; import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; import { ADE_AGENT_SKILLS_DIRS_ENV } from "../../../shared/agentSkillRoots"; +import { GROK_CLAUDE_MARKER_OVERRIDE_ENV } from "../../../shared/grokSupervision"; import type { AgentChatPermissionMode, TerminalSessionSummary } from "../../../shared/types"; const originalPlatform = process.platform; @@ -331,6 +332,170 @@ describe("defaultTrackedCliStartupCommand", () => { expect(defaultTrackedCliStartupCommand("opencode")).toBe("opencode"); expect(defaultTrackedCliStartupCommand("pi")).toBe("pi"); }); + + it("returns launch binaries for the ACP providers", () => { + expect(defaultTrackedCliStartupCommand("qwen")).toBe("qwen"); + expect(defaultTrackedCliStartupCommand("kimi")).toBe("kimi"); + expect(defaultTrackedCliStartupCommand("grok")).toBe("grok --no-alt-screen"); + expect(defaultTrackedCliStartupCommand("copilot")).toBe("copilot --no-alt-screen"); + }); +}); + +describe("ACP CLI providers", () => { + it("assigns a Qwen session id and keeps --yolo off the approval-mode flag", () => { + const launch = withProcessPlatform("darwin", () => buildTrackedCliLaunchCommand({ + provider: "qwen", + permissionMode: "full-auto", + sessionId: "11111111-2222-3333-4444-555555555555", + model: "qwen/qwen3-coder-plus", + initialPrompt: "Fix the failing test.", + })); + expect(launch.command).toBe("qwen"); + expect(launch.assignedSessionId).toBe("11111111-2222-3333-4444-555555555555"); + expect(launch.args).toContain("--session-id"); + expect(launch.args).toEqual(expect.arrayContaining(["-m", "qwen3-coder-plus"])); + expect(launch.args).toEqual(expect.arrayContaining(["--approval-mode", "yolo"])); + expect(launch.args).not.toContain("--yolo"); + // POSIX keeps the prompt in argv; the guidance blob stays off the shell line. + expect(launch.args).toEqual(expect.arrayContaining(["-i", "Fix the failing test."])); + expect(launch.startupCommand).not.toContain("--append-system-prompt"); + expect(launch.initialInput).toBeUndefined(); + }); + + it("moves the Qwen prompt onto the PTY on Windows", () => { + const launch = withProcessPlatform("win32", () => buildTrackedCliLaunchCommand({ + provider: "qwen", + permissionMode: "default", + initialPrompt: "Fix the failing test.", + })); + expect(launch.args).not.toContain("-i"); + expect(launch.initialInput).toBe("Fix the failing test."); + expect(launch.initialInputDelayMs).toBe(750); + }); + + it("gives Kimi no argv prompt and no assigned session id", () => { + const launch = withProcessPlatform("darwin", () => buildTrackedCliLaunchCommand({ + provider: "kimi", + permissionMode: "plan", + sessionId: "11111111-2222-3333-4444-555555555555", + model: "moonshot/k3", + initialPrompt: "Review this lane.", + })); + expect(launch.command).toBe("kimi"); + // `-m` takes a namespaced alias, so the registry prefix is replaced, not dropped. + expect(launch.args).toEqual(["-m", "kimi-code/k3", "--plan"]); + expect(launch.assignedSessionId).toBeUndefined(); + expect(launch.startupCommand).not.toContain("Review this lane."); + expect(launch.initialInput).toBe("Review this lane."); + expect(launch.initialInputDelayMs).toBe(750); + }); + + it("assigns a Grok session with -s and never passes --worktree", () => { + const launch = withProcessPlatform("darwin", () => buildTrackedCliLaunchCommand({ + provider: "grok", + permissionMode: "edit", + sessionId: "11111111-2222-3333-4444-555555555555", + model: "xai/grok-4-6", + reasoningEffort: "ultracode", + initialPrompt: "Ship it.", + })); + expect(launch.command).toBe("grok"); + expect(launch.args).toEqual(expect.arrayContaining(["-s", "11111111-2222-3333-4444-555555555555"])); + expect(launch.args).toEqual(expect.arrayContaining(["-m", "grok-4-6"])); + // ADE's ladder runs past Grok's, so `ultracode` lands on its top tier. + expect(launch.args).toEqual(expect.arrayContaining(["--reasoning-effort", "xhigh"])); + expect(launch.args).toEqual(expect.arrayContaining(["--permission-mode", "acceptEdits"])); + expect(launch.args).not.toContain("-w"); + expect(launch.args).not.toContain("--worktree"); + expect(launch.args.at(-1)).toBe("Ship it."); + expect(launch.startupCommand).not.toContain("--rules"); + // Both halves of the neutralization, on the tracked CLI too: the flag only + // overrides `~/.grok/config.toml`, and the marker only cancels the Claude + // settings import. Neither works alone. + expect(launch.env?.[GROK_CLAUDE_MARKER_OVERRIDE_ENV]).toBe("1"); + expect(launch.args).toEqual(expect.arrayContaining(["--permission-mode", "acceptEdits"])); + }); + + it("carries the Grok claude-import kill switch on resume as well as on launch", () => { + // A resumed TUI re-reads the user's Claude settings on start, so a resume + // that dropped the variable would change the chat's posture on reattach. + const resumed = buildTrackedCliResumeLaunchCommand({ + provider: "grok", + targetKind: "session", + targetId: "11111111-2222-3333-4444-555555555555", + launch: { permissionMode: "default" }, + }); + expect(resumed.env?.[GROK_CLAUDE_MARKER_OVERRIDE_ENV]).toBe("1"); + expect(resumed.args).toEqual(expect.arrayContaining(["--permission-mode", "default"])); + }); + + it("assigns a Copilot session through --resume and maps plan to tool denials", () => { + const launch = withProcessPlatform("darwin", () => buildTrackedCliLaunchCommand({ + provider: "copilot", + permissionMode: "plan", + sessionId: "11111111-2222-3333-4444-555555555555", + model: "github-copilot/gpt-5.4", + initialPrompt: "Plan the change.", + })); + expect(launch.command).toBe("copilot"); + expect(launch.assignedSessionId).toBe("11111111-2222-3333-4444-555555555555"); + expect(launch.args).toContain("--resume=11111111-2222-3333-4444-555555555555"); + expect(launch.args).toEqual(expect.arrayContaining(["--model", "gpt-5.4"])); + expect(launch.args).toEqual(expect.arrayContaining(["--deny-tool=write", "--deny-tool=shell"])); + expect(launch.args).toEqual(expect.arrayContaining(["-i", "Plan the change."])); + }); + + it("rejects the permission modes each ACP provider has no mapping for", () => { + expect(() => validateLaunchProfilePermissionMode("copilot", "auto")).toThrow(/GitHub Copilot/u); + expect(() => validateLaunchProfilePermissionMode("kimi", "auto")).toThrow(/Kimi/u); + expect(() => validateLaunchProfilePermissionMode("qwen", "config-toml")).toThrow(/qwen/u); + expect(() => validateLaunchProfilePermissionMode("grok", "config-toml")).toThrow(/grok/u); + // Both providers with a native auto tier accept it. + expect(() => validateLaunchProfilePermissionMode("qwen", "auto")).not.toThrow(); + expect(() => validateLaunchProfilePermissionMode("grok", "auto")).not.toThrow(); + }); + + it("never emits an assign-at-launch session id on an ACP resume", () => { + const resumeMetadata = (provider: "qwen" | "kimi" | "grok" | "copilot") => ({ + provider, + targetKind: "session" as const, + targetId: "11111111-2222-3333-4444-555555555555", + launch: { permissionMode: "default" as const }, + }); + const qwen = buildTrackedCliResumeLaunchCommand(resumeMetadata("qwen")); + expect(qwen.args).not.toContain("--session-id"); + expect(qwen.args).toEqual(expect.arrayContaining(["--resume", "11111111-2222-3333-4444-555555555555"])); + + const kimi = buildTrackedCliResumeLaunchCommand(resumeMetadata("kimi")); + expect(kimi.args).toEqual(expect.arrayContaining(["-S", "11111111-2222-3333-4444-555555555555"])); + + const grok = buildTrackedCliResumeLaunchCommand(resumeMetadata("grok")); + expect(grok.args).not.toContain("-s"); + expect(grok.args).toEqual(expect.arrayContaining(["--resume", "11111111-2222-3333-4444-555555555555"])); + + const copilot = buildTrackedCliResumeLaunchCommand(resumeMetadata("copilot")); + expect(copilot.args).toContain("--resume=11111111-2222-3333-4444-555555555555"); + }); + + it("continues the most recent ACP session when no target id was captured", () => { + for (const provider of ["qwen", "grok", "copilot"] as const) { + const launch = buildTrackedCliResumeLaunchCommand({ + provider, + targetKind: "session", + targetId: null, + launch: { permissionMode: "default" }, + }); + expect(launch.args).toContain("--continue"); + } + const kimi = buildTrackedCliResumeLaunchCommand({ + provider: "kimi", + targetKind: "session", + targetId: null, + launch: { permissionMode: "default" }, + }); + // Kimi spells continue with a lowercase short flag, not `--continue`. + expect(kimi.args).toContain("-c"); + }); }); describe("orchestration CLI launch policy", () => { diff --git a/apps/desktop/src/renderer/lib/modelOptions.ts b/apps/desktop/src/renderer/lib/modelOptions.ts index 91646e92f6..8e1c206622 100644 --- a/apps/desktop/src/renderer/lib/modelOptions.ts +++ b/apps/desktop/src/renderer/lib/modelOptions.ts @@ -92,6 +92,13 @@ function addAvailableModelIdsByPrefix( } } +const ACP_MODEL_PREFIX_BY_CLI: Partial> = { + qwen: "qwen/", + kimi: "moonshot/", + grok: "xai/", + copilot: "github-copilot/", +}; + function hasDynamicLocalModelIdsForProvider( provider: string, availableModelIds: readonly ModelId[] | undefined, @@ -153,6 +160,8 @@ export function deriveConfiguredModelIds( const familyMap: Record = { claude: "anthropic", codex: "openai" }; const family = auth.cli ? familyMap[auth.cli] : undefined; if (family) addKnownModelIds(ids, family, true); + const acpPrefix = auth.cli ? ACP_MODEL_PREFIX_BY_CLI[auth.cli] : undefined; + if (acpPrefix) addAvailableModelIdsByPrefix(ids, status.availableModelIds, acpPrefix); continue; } diff --git a/apps/desktop/src/renderer/lib/nativeLaunchControls.ts b/apps/desktop/src/renderer/lib/nativeLaunchControls.ts index ca3cc88ea2..5bee0be3b7 100644 --- a/apps/desktop/src/renderer/lib/nativeLaunchControls.ts +++ b/apps/desktop/src/renderer/lib/nativeLaunchControls.ts @@ -19,7 +19,17 @@ import { import type { NativeControlState } from "./draftLaunchJobs"; import { resolveModelDescriptorWithRuntimeCatalog } from "../components/shared/ModelPicker/modelCatalog"; -type ChatRuntimeProviderKey = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi"; +type ChatRuntimeProviderKey = + | "claude" + | "codex" + | "cursor" + | "droid" + | "opencode" + | "pi" + | "qwen" + | "kimi" + | "grok" + | "copilot"; type CliProvider = ChatRuntimeProviderKey; export function defaultNativeControls(profile: ChatSurfaceProfile = "standard"): NativeControlState { diff --git a/apps/desktop/src/renderer/lib/sessions.test.ts b/apps/desktop/src/renderer/lib/sessions.test.ts index cbf68f2897..dc286df4f2 100644 --- a/apps/desktop/src/renderer/lib/sessions.test.ts +++ b/apps/desktop/src/renderer/lib/sessions.test.ts @@ -223,7 +223,11 @@ describe("providerFromChatToolType", () => { }); it("returns null for CLI/shell tool types and unknown input", () => { - for (const toolType of ["shell", "claude", "codex", "cursor-cli", "droid", "opencode", "not-a-tool", "", null, undefined]) { + for (const toolType of [ + "shell", "claude", "codex", "cursor-cli", "droid", "opencode", + "qwen", "kimi", "grok", "copilot", + "not-a-tool", "", null, undefined, + ]) { expect(providerFromChatToolType(toolType)).toBeNull(); } }); diff --git a/apps/desktop/src/renderer/lib/sessions.ts b/apps/desktop/src/renderer/lib/sessions.ts index 0e882a91d1..899179a94e 100644 --- a/apps/desktop/src/renderer/lib/sessions.ts +++ b/apps/desktop/src/renderer/lib/sessions.ts @@ -26,7 +26,11 @@ export function isPtyContextInsertableToolType(toolType: TerminalSessionSummary[ || toolType === "codex" || toolType === "cursor-cli" || toolType === "droid" - || toolType === "opencode"; + || toolType === "opencode" + || toolType === "qwen" + || toolType === "kimi" + || toolType === "grok" + || toolType === "copilot"; } /** @@ -83,7 +87,17 @@ export function canBulkDeleteSession(session: Pick = { claude: "claude-chat", @@ -92,6 +106,10 @@ export const CHAT_TOOL_TYPE_BY_PROVIDER: Record = { @@ -101,6 +119,10 @@ const CHAT_PROVIDER_BY_TOOL_TYPE: Record = { "droid-chat": "droid", "pi-chat": "pi", "opencode-chat": "opencode", + "qwen-chat": "qwen", + "kimi-chat": "kimi", + "grok-chat": "grok", + "copilot-chat": "copilot", }; /** @@ -173,6 +195,14 @@ export function defaultSessionLabel(toolType: string | null | undefined): string if (toolType === "droid") return "Droid CLI session"; if (toolType === "opencode") return "OpenCode CLI session"; if (toolType === "droid-chat") return "Droid chat"; + if (toolType === "qwen-chat") return "Qwen chat"; + if (toolType === "kimi-chat") return "Kimi chat"; + if (toolType === "grok-chat") return "Grok chat"; + if (toolType === "copilot-chat") return "Copilot chat"; + if (toolType === "qwen") return "Qwen CLI session"; + if (toolType === "kimi") return "Kimi CLI session"; + if (toolType === "grok") return "Grok CLI session"; + if (toolType === "copilot") return "Copilot CLI session"; if (toolType === "claude") return "Claude session"; if (toolType === "codex") return "Codex session"; return "Session"; @@ -240,6 +270,10 @@ const SHORT_TOOL_TYPE_LABELS: Record = { droid: "Droid", opencode: "OpenCode", pi: "Pi", + qwen: "Qwen", + kimi: "Kimi", + grok: "Grok", + copilot: "Copilot", aider: "Aider", continue: "Continue", }; @@ -250,6 +284,10 @@ const SHORT_TOOL_TYPE_PREFIXES: readonly [string, string][] = [ ["codex", "Codex"], ["opencode", "OpenCode"], ["pi", "Pi"], + ["qwen", "Qwen"], + ["kimi", "Kimi"], + ["grok", "Grok"], + ["copilot", "Copilot"], ]; /** Resolve a short label via exact match, prefix match, or hyphen-to-space fallback. */ @@ -280,6 +318,14 @@ export function formatToolTypeLabel(toolType: string | null | undefined): string if (toolType === "droid") return "Droid CLI session"; if (toolType === "opencode") return "OpenCode CLI session"; if (toolType === "droid-chat") return "Droid chat"; + if (toolType === "qwen-chat") return "Qwen chat"; + if (toolType === "kimi-chat") return "Kimi chat"; + if (toolType === "grok-chat") return "Grok chat"; + if (toolType === "copilot-chat") return "Copilot chat"; + if (toolType === "qwen") return "Qwen CLI session"; + if (toolType === "kimi") return "Kimi CLI session"; + if (toolType === "grok") return "Grok CLI session"; + if (toolType === "copilot") return "Copilot CLI session"; if (toolType === "claude") return "Claude session"; if (toolType === "codex") return "Codex session"; if (toolType === "shell") return "Terminal session"; diff --git a/apps/desktop/src/shared/acpProviderMetadata.ts b/apps/desktop/src/shared/acpProviderMetadata.ts new file mode 100644 index 0000000000..03f703bfef --- /dev/null +++ b/apps/desktop/src/shared/acpProviderMetadata.ts @@ -0,0 +1,47 @@ +/** + * Identity facts shared by every ADE surface that presents an ACP provider. + * Provider-specific setup prose stays in the Settings descriptor, while ids, + * labels, login commands, and config-home names have one owner. + */ + +export const ACP_PROVIDER_IDS = ["qwen", "kimi", "grok", "copilot"] as const; +export type AcpProviderId = (typeof ACP_PROVIDER_IDS)[number]; + +export type AcpProviderMetadata = { + readonly label: string; + readonly statusLabel: string; + readonly loginCommand: string; + readonly loginHint: string; + readonly configHomeEnv: string | null; +}; + +export const ACP_PROVIDER_METADATA: Readonly> = { + qwen: { + label: "Qwen Code", + statusLabel: "Qwen", + loginCommand: "qwen --auth-type=openai", + loginHint: "configure Qwen Code (`qwen --auth-type=openai` or OPENAI_API_KEY / OPENAI_BASE_URL)", + configHomeEnv: "QWEN_HOME", + }, + kimi: { + label: "Kimi", + statusLabel: "Kimi", + loginCommand: "kimi login", + loginHint: "kimi login (--region global or mainland-cn)", + configHomeEnv: "KIMI_CODE_HOME", + }, + grok: { + label: "Grok", + statusLabel: "Grok", + loginCommand: "grok login", + loginHint: "grok login or set XAI_API_KEY", + configHomeEnv: null, + }, + copilot: { + label: "GitHub Copilot", + statusLabel: "GitHub Copilot", + loginCommand: "copilot login", + loginHint: "copilot login", + configHomeEnv: "COPILOT_HOME", + }, +}; diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 07865861f9..fb0923ef22 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -19,12 +19,23 @@ import { import { buildAdeCliAgentGuidance, buildAdeCliInlineGuidance } from "./adeCliGuidance"; import { isProviderSlashCommandInput } from "./chatSlashCommands"; import { resolveClaudeCliModelAlias } from "./claudeCliModels"; +import { grokSupervisionEnv } from "./grokSupervision"; import { decodeOpenCodeRegistryId, decodePiRegistryId } from "./modelRegistry"; import { effectiveOrchestrationPermissionMode } from "./orchestrationRuntimePolicy"; import { commandArrayToLine, parseCommandLine, quoteShellArg } from "./shell"; import type { OrchestrationRole } from "./types/orchestration"; -export type CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi"; +export type CliProvider = + | "claude" + | "codex" + | "cursor" + | "droid" + | "opencode" + | "pi" + | "qwen" + | "kimi" + | "grok" + | "copilot"; export type LaunchProfile = CliProvider | "shell"; export type TrackedCliLaunchCommand = { command?: string; @@ -138,7 +149,19 @@ export function buildPtyContinuationLaunchFields( }; } -export const LAUNCH_PROFILES = ["claude", "codex", "cursor", "droid", "opencode", "pi", "shell"] as const satisfies readonly LaunchProfile[]; +export const LAUNCH_PROFILES = [ + "claude", + "codex", + "cursor", + "droid", + "opencode", + "pi", + "qwen", + "kimi", + "grok", + "copilot", + "shell", +] as const satisfies readonly LaunchProfile[]; export const TRACKED_CLI_PERMISSION_MODES = ["default", "auto", "plan", "edit", "full-auto", "config-toml"] as const satisfies readonly AgentChatPermissionMode[]; export function sanitizeTrackedCliResumeTargetId(value: string | null | undefined): string | null { @@ -158,6 +181,10 @@ export const LAUNCH_PROFILE_TOOL_TYPE: Record = droid: "droid", opencode: "opencode", pi: "pi", + qwen: "qwen", + kimi: "kimi", + grok: "grok", + copilot: "copilot", shell: "shell", }; @@ -169,6 +196,10 @@ export const LAUNCH_PROFILE_TITLE: Record = { droid: "Factory Droid CLI", opencode: "OpenCode CLI", pi: "Pi CLI", + qwen: "Qwen Code CLI", + kimi: "Kimi Code CLI", + grok: "Grok CLI", + copilot: "GitHub Copilot CLI", shell: "Shell", }; @@ -323,6 +354,10 @@ const LAUNCH_PROFILE_TOOL_TYPES: Record arg !== "--append-system-prompt" && all[i - 1] !== "--append-system-prompt", + ); + return { + command: "qwen", + args: commandArgs, + startupCommand: commandArrayToLine(["qwen", ...shellArgs], { platform: "linux" }), + ...(assignedSessionId ? { assignedSessionId } : {}), + ...(initialPrompt && !promptRidesInArgv + ? { initialInput: initialPrompt, initialInputDelayMs: 750 } + : {}), + ...(agentSkillEnv ? { env: agentSkillEnv } : {}), + }; + } + + if (args.provider === "kimi") { + // Kimi's interactive TUI takes no argv prompt at all, so the prompt is + // typed in after launch — the Cursor branch's shape. Kimi also cannot be + // handed a session id at launch; the id is captured from its sessions + // directory afterwards, so no `assignedSessionId` is returned here. + const commandArgs = [ + ...kimiModelFlags(args.model), + ...permissionModeToKimiFlags(permissionMode), + ]; + return { + command: "kimi", + args: commandArgs, + startupCommand: commandArrayToLine(["kimi", ...commandArgs], { platform: "linux" }), + ...(initialPrompt ? { initialInput: initialPrompt, initialInputDelayMs: 750 } : {}), + ...(agentSkillEnv ? { env: agentSkillEnv } : {}), + }; + } + + if (args.provider === "grok") { + const assignedSessionId = args.sessionId?.trim() || null; + const commandArgs: string[] = ["--no-alt-screen"]; + if (assignedSessionId) { + // `-s` names a NEW session's UUID; with `--resume`/`--continue` it is + // only legal alongside `--fork-session`. Fresh launches only. + commandArgs.push("-s", assignedSessionId); + } + commandArgs.push(...grokModelFlags(args.model)); + commandArgs.push(...grokReasoningEffortFlags(args.reasoningEffort)); + commandArgs.push(...permissionModeToGrokFlags(permissionMode)); + commandArgs.push("--rules", buildAdeCliAgentGuidance(skillRoots)); + const promptRidesInArgv = Boolean(initialPrompt) && currentPlatform() !== "win32"; + if (initialPrompt && promptRidesInArgv) { + commandArgs.push(initialPrompt); + } + const shellArgs = commandArgs.filter( + (arg, i, all) => arg !== "--rules" && all[i - 1] !== "--rules", + ); + return { + command: "grok", + args: commandArgs, + startupCommand: commandArrayToLine(["grok", ...shellArgs], { platform: "linux" }), + ...(assignedSessionId ? { assignedSessionId } : {}), + ...(initialPrompt && !promptRidesInArgv + ? { initialInput: initialPrompt, initialInputDelayMs: 750 } + : {}), + // `--permission-mode` above only overrides `~/.grok/config.toml`. Without + // the Claude-import kill switch beside it, Grok still merges the user's + // `~/.claude/settings.json` `permissions.defaultMode` and auto-approves + // writes in its own TUI too. The two halves travel together everywhere. + env: { ...(agentSkillEnv ?? {}), ...grokSupervisionEnv() }, + }; + } + + if (args.provider === "copilot") { + const assignedSessionId = args.sessionId?.trim() || null; + const commandArgs: string[] = ["--no-alt-screen"]; + if (assignedSessionId) { + // Copilot has no separate assign flag: `--resume=` starts a new + // session under that id when the id does not exist yet, and resumes it + // when it does. One spelling, both jobs. + commandArgs.push(`--resume=${assignedSessionId}`); + } + commandArgs.push(...copilotModelFlags(args.model)); + commandArgs.push(...copilotReasoningEffortFlags(args.reasoningEffort)); + commandArgs.push(...permissionModeToCopilotFlags(permissionMode)); + const promptRidesInArgv = Boolean(initialPrompt) && currentPlatform() !== "win32"; + if (initialPrompt && promptRidesInArgv) { + commandArgs.push("-i", initialPrompt); + } + return { + command: "copilot", + args: commandArgs, + startupCommand: commandArrayToLine(["copilot", ...commandArgs], { platform: "linux" }), + ...(assignedSessionId ? { assignedSessionId } : {}), + ...(initialPrompt && !promptRidesInArgv + ? { initialInput: initialPrompt, initialInputDelayMs: 750 } + : {}), + ...(agentSkillEnv ? { env: agentSkillEnv } : {}), + }; + } + // Only the user's own text rides `--prompt`. OpenCode submits that value as a // real user message and renders it in the TUI, so the ADE preamble that used // to be prepended here was displayed to the user verbatim on every launch — @@ -1063,6 +1242,146 @@ function codexResumePermissionFlags(args: { return permissionModeToCodexFlags(args.permissionMode); } +/** + * ACP providers take the model id verbatim; ADE only strips its own registry + * prefix. None of the four publishes a fixed enum any more — every one resolves + * its catalog from the server at auth time — so an id ADE does not recognise is + * forwarded rather than rejected, and the CLI gives the real error. + */ +function stripRegistryPrefix(model: string | null | undefined, prefix: string): string | null { + const raw = normalizeCliFlagValue(model); + if (!raw) return null; + const slash = raw.indexOf("/"); + if (slash > 0 && raw.slice(0, slash).toLowerCase() === prefix) { + return raw.slice(slash + 1).trim() || null; + } + return raw; +} + +export function resolveQwenCliModelForLaunch(model: string | null | undefined): string | null { + return stripRegistryPrefix(model, "qwen"); +} + +/** + * Kimi's `-m` takes a config alias, not a raw model id, and the alias is always + * namespaced (`kimi-code/k3`). A bare `k3` fails with "Unknown model alias", so + * ADE restores the namespace when its own registry prefix stripped it away. + */ +export function resolveKimiCliModelForLaunch(model: string | null | undefined): string | null { + const raw = stripRegistryPrefix(model, "moonshot"); + if (!raw) return null; + return raw.includes("/") ? raw : `kimi-code/${raw}`; +} + +export function resolveGrokCliModelForLaunch(model: string | null | undefined): string | null { + return stripRegistryPrefix(model, "xai"); +} + +export function resolveCopilotCliModelForLaunch(model: string | null | undefined): string | null { + return stripRegistryPrefix(model, "github-copilot"); +} + +function qwenModelFlags(model: string | null | undefined): string[] { + const resolved = resolveQwenCliModelForLaunch(model); + return resolved ? ["-m", resolved] : []; +} + +function kimiModelFlags(model: string | null | undefined): string[] { + const resolved = resolveKimiCliModelForLaunch(model); + return resolved ? ["-m", resolved] : []; +} + +function grokModelFlags(model: string | null | undefined): string[] { + const resolved = resolveGrokCliModelForLaunch(model); + return resolved ? ["-m", resolved] : []; +} + +function copilotModelFlags(model: string | null | undefined): string[] { + const resolved = resolveCopilotCliModelForLaunch(model); + return resolved ? ["--model", resolved] : []; +} + +const GROK_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"] as const; + +export function grokReasoningEffortFlags(reasoningEffort: string | null | undefined): string[] { + const effort = normalizeCliFlagValue(reasoningEffort)?.toLowerCase(); + if (!effort) return []; + // ADE's ladder runs past Grok's: "max" and "ultracode" have no Grok tier, so + // they land on its highest rather than being passed through and rejected. + const mapped = effort === "max" || effort === "ultracode" ? "xhigh" : effort; + if (!(GROK_REASONING_EFFORTS as readonly string[]).includes(mapped)) return []; + return ["--reasoning-effort", mapped]; +} + +const COPILOT_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"] as const; + +export function copilotReasoningEffortFlags(reasoningEffort: string | null | undefined): string[] { + const effort = normalizeCliFlagValue(reasoningEffort)?.toLowerCase(); + if (!effort) return []; + const mapped = effort === "max" || effort === "ultracode" ? "xhigh" : effort; + if (!(COPILOT_REASONING_EFFORTS as readonly string[]).includes(mapped)) return []; + return ["--reasoning-effort", mapped]; +} + +/** + * Qwen's approval ladder is plan | default | auto-edit | auto | yolo, set with + * a single `--approval-mode`. `--yolo` is the older spelling of the same + * setting: passing both makes Qwen reject the launch, so ADE only ever emits + * `--approval-mode`. + */ +export function permissionModeToQwenFlags( + permissionMode: AgentChatPermissionMode | null | undefined, +): string[] { + if (permissionMode == null) return []; + if (permissionMode === "full-auto") return ["--approval-mode", "yolo"]; + if (permissionMode === "auto") return ["--approval-mode", "auto"]; + if (permissionMode === "edit") return ["--approval-mode", "auto-edit"]; + if (permissionMode === "plan") return ["--approval-mode", "plan"]; + return ["--approval-mode", "default"]; +} + +/** Kimi's `--plan`, `--auto`, and `--yolo` are mutually exclusive switches. */ +export function permissionModeToKimiFlags( + permissionMode: AgentChatPermissionMode | null | undefined, +): string[] { + if (permissionMode === "full-auto") return ["--yolo"]; + if (permissionMode === "edit") return ["--auto"]; + if (permissionMode === "plan") return ["--plan"]; + return []; +} + +/** + * Grok's own `--permission-mode` values line up with ADE's ladder one for one. + * ADE never passes `-w/--worktree`: Grok would create a second git worktree + * inside the lane worktree ADE already made. + */ +export function permissionModeToGrokFlags( + permissionMode: AgentChatPermissionMode | null | undefined, +): string[] { + if (permissionMode == null) return []; + if (permissionMode === "full-auto") return ["--permission-mode", "bypassPermissions"]; + if (permissionMode === "auto") return ["--permission-mode", "auto"]; + if (permissionMode === "edit") return ["--permission-mode", "acceptEdits"]; + if (permissionMode === "plan") return ["--permission-mode", "plan"]; + return ["--permission-mode", "default"]; +} + +/** + * Copilot has no plan mode and no approval ladder — only allow/deny tool + * patterns. Plan becomes the two denials that make a session read-only + * (`write` covers every file-creating tool, `shell` every command), which is + * the closest honest equivalent. `validateLaunchProfilePermissionMode` already + * rejected the two modes with no mapping at all. + */ +export function permissionModeToCopilotFlags( + permissionMode: AgentChatPermissionMode | null | undefined, +): string[] { + if (permissionMode === "full-auto") return ["--allow-all-tools"]; + if (permissionMode === "edit") return ["--allow-tool=write"]; + if (permissionMode === "plan") return ["--deny-tool=write", "--deny-tool=shell"]; + return []; +} + function permissionModeToCursorFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { if (permissionMode === "full-auto") return ["--force"]; if (permissionMode === "plan") return ["--mode", "plan"]; @@ -1548,6 +1867,89 @@ export function buildTrackedCliResumeLaunchCommand( }; } + if (metadata.provider === "qwen") { + const parts = [ + "qwen", + ...qwenModelFlags(model), + ...permissionModeToQwenFlags(permissionMode), + ]; + // `--session-id` is never emitted here: it starts a new conversation and is + // mutually exclusive with the resume selector, exactly as with Claude. + if (targetId) parts.push("--resume", targetId); + else parts.push("--continue"); + const promptRidesInArgv = Boolean(prompt) && (options.platform ?? process.platform) !== "win32"; + if (prompt && promptRidesInArgv) parts.push("-i", prompt); + return { + command: parts[0]!, + args: parts.slice(1), + startupCommand: commandArrayToLine(parts, { platform: "linux" }), + ...(prompt && !promptRidesInArgv ? { initialInput: prompt, initialInputDelayMs: 750 } : {}), + }; + } + + if (metadata.provider === "kimi") { + const parts = [ + "kimi", + ...kimiModelFlags(model), + ...permissionModeToKimiFlags(permissionMode), + ]; + // Lowercase `-c`, and `-S` for a session id — Kimi's resume selectors do + // not follow the `--resume` spelling every other provider here uses. + if (targetId) parts.push("-S", targetId); + else parts.push("-c"); + return { + command: parts[0]!, + args: parts.slice(1), + startupCommand: commandArrayToLine(parts, { platform: "linux" }), + // Kimi's TUI takes no argv prompt on resume either. + ...(prompt ? { initialInput: prompt, initialInputDelayMs: 750 } : {}), + }; + } + + if (metadata.provider === "grok") { + const parts = [ + "grok", + "--no-alt-screen", + ...grokModelFlags(model), + ...grokReasoningEffortFlags(reasoningEffort), + ...permissionModeToGrokFlags(permissionMode), + ]; + if (targetId) parts.push("--resume", targetId); + else parts.push("--continue"); + const promptRidesInArgv = Boolean(prompt) && (options.platform ?? process.platform) !== "win32"; + if (prompt && promptRidesInArgv) parts.push(prompt); + return { + command: parts[0]!, + args: parts.slice(1), + startupCommand: commandArrayToLine(parts, { platform: "linux" }), + ...(prompt && !promptRidesInArgv ? { initialInput: prompt, initialInputDelayMs: 750 } : {}), + // A resumed Grok TUI re-reads the user's Claude settings on start, so the + // kill switch has to ride the resume too. Fresh launch and resume must + // agree, or the same chat changes posture when it is reattached. + env: grokSupervisionEnv(), + }; + } + + if (metadata.provider === "copilot") { + const parts = [ + "copilot", + "--no-alt-screen", + ...copilotModelFlags(model), + ...copilotReasoningEffortFlags(reasoningEffort), + ...permissionModeToCopilotFlags(permissionMode), + ]; + if (targetId) parts.push(`--resume=${targetId}`); + else parts.push("--continue"); + const promptRidesInArgv = Boolean(prompt) && (options.platform ?? process.platform) !== "win32"; + if (prompt && promptRidesInArgv) parts.push("-i", prompt); + return { + command: parts[0]!, + args: parts.slice(1), + startupCommand: commandArrayToLine(parts, { platform: "linux" }), + ...(prompt && !promptRidesInArgv ? { initialInput: prompt, initialInputDelayMs: 750 } : {}), + }; + } + const opencode = buildOpenCodeCommandParts({ permissionMode, model, diff --git a/apps/desktop/src/shared/grokSupervision.ts b/apps/desktop/src/shared/grokSupervision.ts new file mode 100644 index 0000000000..651fce0f90 --- /dev/null +++ b/apps/desktop/src/shared/grokSupervision.ts @@ -0,0 +1,64 @@ +/** + * The environment half of Grok's approval neutralization. + * + * It lives in `shared/` because both Grok launch paths need the identical + * value: the ACP dialect's spawn plan + * (`main/services/chat/acpHost/acpDialects/grok.ts`) and the tracked-CLI + * launcher (`shared/cliLaunch.ts`). + * + * ## What the variable does + * + * Grok merges permission RULES from several sources and evaluates MODE flags + * only AFTER those rules, so no CLI flag, `startupHints` value, or ACP `_meta` + * field can force ask-always on its own. One of the merged sources is the + * user's `~/.claude/settings.json`; its `permissions.defaultMode` — not the + * allow rules beside it — is what seeds Grok's auto-classifier and silently + * approves file writes inside ADE. + * + * Grok's `permission/claude_settings.rs::is_claude_import_marked` reads this + * variable and, when it is set, skips both `resolve_permissions_with_provenance` + * and `load_claude_env_with_project`. With it set, `grok inspect` reports + * `Permissions └ Source: (none) └ 0 loaded`, and a cwd write raises a real + * `session/request_permission` that ADE can reject. + * + * ## Both halves are required + * + * - `--permission-mode ` cancels the user's own `~/.grok/config.toml` + * `[ui] permission_mode`. + * - `_GROK_CLAUDE_MARKER_OVERRIDE=1` cancels the Claude settings import. + * + * A live six-arm probe on Grok 1.0.13 proved neither half works alone: + * dropping the mode flag re-broke approvals even with the variable set, and the + * mode flag alone was the state that shipped while writes still auto-approved. + * + * ## Why this route and not the config route + * + * Setting `[claude_compat] imported = true` in `~/.grok/config.toml` + * neutralizes the permission import too, but it additionally strips every + * Claude-derived capability: skills 50 -> 47, agents 11 -> 3, MCP servers + * 4 -> 2. The environment variable is surgical — skills, agents, MCP servers, + * and `Claude.md` all still load. It also writes nothing to the user's machine. + * + * ## Risk + * + * The leading underscore is Grok's own convention for a vendor-internal hatch. + * It is undocumented and may be renamed or removed in any release, and Grok + * ships roughly daily. ADE therefore never trusts it blindly: the preflight in + * `main/services/ai/grokPermissionPreflight.ts` verifies the effect against + * `grok inspect`, and the provider-agnostic supervision invariant in + * `acpHost/acpSupervisionGuard.ts` catches the failure at runtime if the + * preflight is somehow wrong. + */ + +/** Name of Grok's undocumented Claude-import kill switch. */ +export const GROK_CLAUDE_MARKER_OVERRIDE_ENV = "_GROK_CLAUDE_MARKER_OVERRIDE"; + +/** + * Environment ADE adds to every Grok child process. + * + * Always pair it with `--permission-mode`; see the module comment for why + * neither half works alone. + */ +export function grokSupervisionEnv(): Record { + return { [GROK_CLAUDE_MARKER_OVERRIDE_ENV]: "1" }; +} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 91b5546310..043f7835dc 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -609,6 +609,12 @@ export const IPC = { aiListApiKeys: "ade.ai.listApiKeys", aiVerifyApiKey: "ade.ai.verifyApiKey", aiUpdateConfig: "ade.ai.updateConfig", + /** + * Binary path, config home, version, last auth verdict, and optionally the + * vendor's own `doctor`, for one ACP provider. Spawns, so it is called when a + * provider's settings page opens — never on a status refresh. + */ + aiAcpProviderDiagnostics: "ade.ai.acpProviderDiagnostics", aiOpencodeAuthMethods: "ade.ai.opencodeAuthMethods", aiOpencodeOAuthStart: "ade.ai.opencodeOAuthStart", aiOpencodeOAuthCancel: "ade.ai.opencodeOAuthCancel", diff --git a/apps/desktop/src/shared/modelCatalog.test.ts b/apps/desktop/src/shared/modelCatalog.test.ts index 73e5381673..60a8431c62 100644 --- a/apps/desktop/src/shared/modelCatalog.test.ts +++ b/apps/desktop/src/shared/modelCatalog.test.ts @@ -1,6 +1,36 @@ import { describe, expect, it } from "vitest"; -import { buildProviderGroupBlocks, createModelOrderMap } from "./modelCatalog"; -import { createDynamicPiModelDescriptor } from "./modelRegistry"; +import { + buildProviderGroupBlocks, + createModelOrderMap, + MODEL_PICKER_PROVIDER_ORDER, +} from "./modelCatalog"; +import { createDynamicPiModelDescriptor, MODEL_REGISTRY } from "./modelRegistry"; + +describe("model picker provider order", () => { + it("keeps the canonical provider sequence after favorites and recents", () => { + expect(MODEL_PICKER_PROVIDER_ORDER).toEqual([ + "claude", + "codex", + "cursor", + "opencode", + "pi", + "copilot", + "grok", + "droid", + "kimi", + "qwen", + "ollama", + "lmstudio", + ]); + }); + + it("sorts populated catalog groups in canonical order", () => { + const groups = buildProviderGroupBlocks(MODEL_REGISTRY, createModelOrderMap(), undefined, false); + const present = groups.map((group) => group.key); + + expect(present).toEqual(MODEL_PICKER_PROVIDER_ORDER.filter((group) => present.includes(group))); + }); +}); describe("Pi model catalog grouping", () => { it("keeps branded provider labels in the Pi rail and subsection", () => { @@ -8,7 +38,8 @@ describe("Pi model catalog grouping", () => { profileId: "work", }); - const [group] = buildProviderGroupBlocks([model], createModelOrderMap()); + const group = buildProviderGroupBlocks([model], createModelOrderMap()) + .find((candidate) => candidate.key === "pi"); expect(group?.key).toBe("pi"); expect(group?.label).toBe("Pi"); @@ -23,7 +54,8 @@ describe("Pi model catalog grouping", () => { createDynamicPiModelDescriptor("openai-codex", "gpt-5.5", { profileId: "team" }), ]; - const [group] = buildProviderGroupBlocks(models, createModelOrderMap()); + const group = buildProviderGroupBlocks(models, createModelOrderMap()) + .find((candidate) => candidate.key === "pi"); const subsections = group?.providers[0]?.subsections ?? []; expect(subsections).toHaveLength(2); diff --git a/apps/desktop/src/shared/modelCatalog.ts b/apps/desktop/src/shared/modelCatalog.ts index 19c958a4fa..74e28dab58 100644 --- a/apps/desktop/src/shared/modelCatalog.ts +++ b/apps/desktop/src/shared/modelCatalog.ts @@ -31,6 +31,9 @@ export const PROVIDER_CATEGORY_MAP: Record = { openrouter: "router", ollama: "local", lmstudio: "local", + qwen: "cloud-api", + moonshot: "cloud-api", + "github-copilot": "cloud-api", }; export const PROVIDER_CATEGORY_LABELS: Record = { @@ -63,6 +66,26 @@ export type ModelProviderGroupBlock = { providers: ModelProviderBlock[]; }; +/** + * Canonical provider rail order for every model-picker surface. Favorites and + * recents are added by each client before this list; these are the runtime + * group keys behind the user-facing Anthropic/OpenAI/etc. labels. + */ +export const MODEL_PICKER_PROVIDER_ORDER = [ + "claude", + "codex", + "cursor", + "opencode", + "pi", + "copilot", + "grok", + "droid", + "kimi", + "qwen", + "ollama", + "lmstudio", +] as const satisfies readonly ProviderGroupKey[]; + const PROVIDER_LABELS: Record = { opencode: "OpenCode (Free)", anthropic: "Anthropic", @@ -82,6 +105,8 @@ const PROVIDER_LABELS: Record = { groq: "Groq", together: "Together", meta: "Meta", + qwen: "Qwen", + moonshot: "Moonshot", }; export const PROVIDER_BADGE_COLORS: Record = { @@ -103,6 +128,8 @@ export const PROVIDER_BADGE_COLORS: Record = { groq: "#06B6D4", together: "#22C55E", meta: "#3B82F6", + qwen: "#6D4AFF", + moonshot: "#1F1F1F", }; export const PROVIDER_ORDER: string[] = [ @@ -112,6 +139,8 @@ export const PROVIDER_ORDER: string[] = [ "openai-codex", "google", "github-copilot", + "qwen", + "moonshot", "deepseek", "mistral", "xai", @@ -125,16 +154,9 @@ export const PROVIDER_ORDER: string[] = [ "pi", ]; -const PROVIDER_GROUP_ORDER: Record = { - claude: 10, - codex: 20, - cursor: 30, - droid: 35, - pi: 38, - opencode: 40, - ollama: 50, - lmstudio: 60, -}; +const PROVIDER_GROUP_ORDER = Object.fromEntries( + MODEL_PICKER_PROVIDER_ORDER.map((groupKey, index) => [groupKey, index]), +) as Record; export const PROVIDER_GROUP_COLORS: Record = { claude: "#D97706", @@ -142,6 +164,10 @@ export const PROVIDER_GROUP_COLORS: Record = { cursor: "#A78BFA", droid: "#6B7280", pi: "#F97316", + qwen: "#6D4AFF", + kimi: "#1F1F1F", + grok: "#DC2626", + copilot: "#8B5CF6", opencode: "#2563EB", ollama: "#71717A", lmstudio: "#64748B", @@ -184,25 +210,28 @@ export function classifyProviderGroup(model: ModelDescriptor): ProviderGroupKey return resolveProviderGroupForModel(model); } +/** + * Group labels, as an exhaustive table rather than a switch with a default. + * A new group is a compile error here, not a row that silently renders under + * its own key. + */ +const PROVIDER_GROUP_LABELS: Record = { + claude: "Claude", + codex: "Codex", + cursor: "Cursor", + droid: "Droid", + pi: "Pi", + qwen: "Qwen", + kimi: "Kimi", + grok: "Grok", + copilot: "GitHub Copilot", + opencode: "OpenCode", + ollama: "Ollama", + lmstudio: "LM Studio", +}; + export function providerGroupLabel(group: ProviderGroupKey): string { - switch (group) { - case "claude": - return "Claude"; - case "codex": - return "Codex"; - case "cursor": - return "Cursor"; - case "droid": - return "Droid"; - case "pi": - return "Pi"; - case "opencode": - return "OpenCode"; - case "ollama": - return "Ollama"; - case "lmstudio": - return "LM Studio"; - } + return PROVIDER_GROUP_LABELS[group]; } export function subsectionKeyForModel(model: ModelDescriptor, group: ProviderGroupKey): string { diff --git a/apps/desktop/src/shared/modelRegistry.test.ts b/apps/desktop/src/shared/modelRegistry.test.ts index f84f40edba..7bc718cb10 100644 --- a/apps/desktop/src/shared/modelRegistry.test.ts +++ b/apps/desktop/src/shared/modelRegistry.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { + createDynamicAcpModelDescriptor, + clearDynamicAcpModelDescriptors, createDynamicDroidCliModelDescriptor, + replaceDynamicAcpModelDescriptors, createDynamicLocalModelDescriptor, createDynamicOpenCodeModelDescriptor, createDynamicPiModelDescriptor, @@ -18,12 +21,14 @@ import { getModelById, getModelDescriptorForPermissionMode, getRuntimeModelRefForDescriptor, + listAcpModelDescriptorsForProvider, listModelDescriptorsForProvider, MODEL_REGISTRY, replaceDynamicPiModelDescriptors, resolveModelAlias, resolveCursorCliModelVariant, resolveCliProviderForModel, + resolveProviderGroupForModel, resolveModelDescriptor, resolveModelDescriptorForProvider, resolveModelSlug, @@ -81,6 +86,76 @@ describe("modelRegistry", () => { expect(descriptor.family).toBe("openai"); }); + it("routes every ACP provider's curated rows to its own group", () => { + const expectations = [ + { provider: "qwen", family: "qwen" }, + { provider: "kimi", family: "moonshot" }, + { provider: "grok", family: "xai" }, + { provider: "copilot", family: "github-copilot" }, + ] as const; + for (const { provider, family } of expectations) { + const models = listModelDescriptorsForProvider(provider); + expect(models.length).toBeGreaterThan(0); + for (const descriptor of models) { + expect(descriptor.family).toBe(family); + expect(descriptor.isCliWrapped).toBe(true); + expect(resolveCliProviderForModel(descriptor)).toBe(provider); + expect(resolveProviderGroupForModel(descriptor)).toBe(provider); + // The CLI flag needs the provider's own id, never ADE's registry id. + expect(getRuntimeModelRefForDescriptor(descriptor)).toBe(descriptor.providerModelId); + } + expect(getDefaultModelDescriptor(provider)).toBe(models[0]); + } + }); + + it("prefers a Qwen model discovered from the CLI settings over curated Alibaba rows", () => { + try { + replaceDynamicAcpModelDescriptors("qwen", [ + createDynamicAcpModelDescriptor("qwen", "gpt-5.5"), + ]); + const models = listModelDescriptorsForProvider("qwen"); + expect(models[0]?.providerModelId).toBe("gpt-5.5"); + expect(getDefaultModelDescriptor("qwen")?.providerModelId).toBe("gpt-5.5"); + expect(models.some((model) => model.providerModelId === "qwen3-coder-plus")).toBe(true); + } finally { + clearDynamicAcpModelDescriptors(); + } + }); + + it("uses configured Qwen model ids instead of presenting unrelated curated rows", () => { + try { + replaceDynamicAcpModelDescriptors("qwen", [ + createDynamicAcpModelDescriptor("qwen", "gpt-5.5"), + ]); + expect(listAcpModelDescriptorsForProvider("qwen", { + configuredModelIds: ["gpt-5.5"], + }).map((model) => model.providerModelId)).toEqual(["gpt-5.5"]); + } finally { + clearDynamicAcpModelDescriptors(); + } + }); + + it("keeps OpenCode-routed xAI and Moonshot models out of the Grok and Kimi groups", () => { + // Family alone would misroute these: only `isCliWrapped` separates a Grok + // CLI row from an OpenCode-routed xAI row that shares its family. + for (const family of ["xai", "moonshot"] as const) { + const routed = MODEL_REGISTRY.filter((m) => m.family === family && !m.isCliWrapped); + for (const descriptor of routed) { + expect(resolveCliProviderForModel(descriptor)).toBeNull(); + expect(resolveProviderGroupForModel(descriptor)).toBe("opencode"); + } + } + }); + + it("marks the preview-tier ACP providers and leaves the first-class ones unmarked", () => { + for (const provider of ["grok", "copilot"] as const) { + expect(listModelDescriptorsForProvider(provider).every((m) => m.previewTier === true)).toBe(true); + } + for (const provider of ["qwen", "kimi"] as const) { + expect(listModelDescriptorsForProvider(provider).some((m) => m.previewTier === true)).toBe(false); + } + }); + it("rejects ambiguous or incomplete Pi registry components", () => { expect(() => encodePiRegistryId("default", "", "gpt-5.4")).toThrow("Pi provider id is required"); expect(() => encodePiRegistryId("default", "openai/codex", "gpt-5.4")).toThrow("cannot contain"); diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index c1d8ee709d..83797c5723 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -19,7 +19,10 @@ export type ProviderFamily = | "lmstudio" | "cursor" | "factory" - | "pi"; + | "pi" + | "qwen" + | "moonshot" + | "github-copilot"; export type LocalProviderFamily = Extract; @@ -87,6 +90,11 @@ export type ModelDescriptor = { cursorAvailability?: CursorModelAvailability; /** Concrete Cursor CLI ids reachable from an abstract picker row. */ cursorCliVariants?: CursorCliModelVariant[]; + /** + * The provider ships in ADE behind a preview label. Settings renders the chip; + * pickers ignore it and render every provider identically. + */ + previewTier?: boolean; }; export type DynamicLocalModelDescriptorOptions = { @@ -101,7 +109,31 @@ export type DynamicLocalModelDescriptorOptions = { }; export type WorkerExecutionPath = "cli" | "api" | "local"; -export type ModelProviderGroup = "claude" | "codex" | "opencode" | "cursor" | "droid" | "pi"; +export type ModelProviderGroup = + | "claude" + | "codex" + | "opencode" + | "cursor" + | "droid" + | "pi" + | "qwen" + | "kimi" + | "grok" + | "copilot"; + +/** Every provider group, in the order surfaces list them. */ +export const MODEL_PROVIDER_GROUPS = [ + "claude", + "codex", + "cursor", + "opencode", + "pi", + "copilot", + "grok", + "droid", + "kimi", + "qwen", +] as const satisfies readonly ModelProviderGroup[]; /** Select a valid reasoning tier without duplicating fallback policy in each UI. */ export function selectSupportedReasoningEffort(args: { @@ -120,7 +152,7 @@ export function selectSupportedReasoningEffort(args: { } export function isModelProviderGroup(value: string | null | undefined): value is ModelProviderGroup { - return value === "claude" || value === "codex" || value === "opencode" || value === "cursor" || value === "droid" || value === "pi"; + return value != null && (MODEL_PROVIDER_GROUPS as readonly string[]).includes(value); } export function modelSupportsServiceTier( @@ -604,6 +636,233 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ costTier: "medium", }, + // ---- Qwen (CLI-wrapped via `qwen`, ACP) ---- + // Curated Alibaba Cloud Coding Plan rows — the models a person actually + // picks, not the whole catalog. `qwen --model` is free-form, so live + // discovery adds anything else the account can reach. + { + id: "qwen/qwen3-coder-plus", + shortId: "qwen3-coder-plus", + aliases: ["qwen3-coder-plus", "qwen-coder"], + displayName: "Qwen3 Coder Plus", + family: "qwen", + authTypes: ["cli-subscription"], + contextWindow: 1_000_000, + maxOutputTokens: 65_536, + capabilities: NO_REASONING, + color: "#6D4AFF", + providerRoute: "qwen-acp", + providerModelId: "qwen3-coder-plus", + cliCommand: "qwen", + isCliWrapped: true, + }, + { + id: "qwen/qwen3-coder-next", + shortId: "qwen3-coder-next", + aliases: ["qwen3-coder-next"], + displayName: "Qwen3 Coder Next", + family: "qwen", + authTypes: ["cli-subscription"], + contextWindow: 262_144, + maxOutputTokens: 65_536, + capabilities: NO_REASONING, + color: "#6D4AFF", + providerRoute: "qwen-acp", + providerModelId: "qwen3-coder-next", + cliCommand: "qwen", + isCliWrapped: true, + }, + { + id: "qwen/qwen3.7-plus", + shortId: "qwen3.7-plus", + aliases: ["qwen3.7-plus", "qwen3-7-plus"], + displayName: "Qwen3.7 Plus", + family: "qwen", + authTypes: ["cli-subscription"], + contextWindow: 1_000_000, + maxOutputTokens: 65_536, + capabilities: ALL_CAPS, + color: "#5B3EE8", + providerRoute: "qwen-acp", + providerModelId: "qwen3.7-plus", + cliCommand: "qwen", + isCliWrapped: true, + }, + + // ---- Kimi (CLI-wrapped via `kimi`, ACP) ---- + // `kimi -m` takes a config ALIAS, never a raw model id, and the alias is + // always namespaced. `providerModelId` therefore carries the whole + // `kimi-code/` alias, which is what the launch flag needs. + { + id: "moonshot/k3", + shortId: "kimi-k3", + aliases: ["kimi-k3", "kimi-code/k3"], + displayName: "Kimi K3", + family: "moonshot", + authTypes: ["cli-subscription"], + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + capabilities: ALL_CAPS, + reasoningTiers: ["low", "high", "max"], + defaultReasoningEffort: "max", + color: "#1F1F1F", + providerRoute: "kimi-acp", + providerModelId: "kimi-code/k3", + cliCommand: "kimi", + isCliWrapped: true, + }, + { + id: "moonshot/kimi-for-coding", + shortId: "kimi-for-coding", + aliases: ["kimi-code/kimi-for-coding"], + displayName: "Kimi for Coding", + family: "moonshot", + authTypes: ["cli-subscription"], + contextWindow: 262_144, + maxOutputTokens: 65_536, + capabilities: ALL_CAPS, + color: "#1F1F1F", + providerRoute: "kimi-acp", + providerModelId: "kimi-code/kimi-for-coding", + cliCommand: "kimi", + isCliWrapped: true, + }, + { + id: "moonshot/kimi-for-coding-highspeed", + shortId: "kimi-for-coding-highspeed", + aliases: ["kimi-code/kimi-for-coding-highspeed"], + displayName: "Kimi for Coding (High Speed)", + family: "moonshot", + authTypes: ["cli-subscription"], + contextWindow: 262_144, + maxOutputTokens: 65_536, + capabilities: ALL_CAPS, + color: "#3F3F46", + providerRoute: "kimi-acp", + providerModelId: "kimi-code/kimi-for-coding-highspeed", + cliCommand: "kimi", + isCliWrapped: true, + }, + + // ---- Grok (CLI-wrapped via `grok`, ACP, preview) ---- + // Verified against the CLI's own live model cache: two visible models, both + // 500K context, and `xhigh` effort only on 4.6. + { + id: "xai/grok-4-6", + shortId: "grok-4.6", + aliases: ["grok-4.6", "grok"], + displayName: "Grok 4.6", + family: "xai", + authTypes: ["cli-subscription"], + contextWindow: 500_000, + maxOutputTokens: 64_000, + capabilities: NO_REASONING, + reasoningTiers: ["low", "medium", "high", "xhigh"], + defaultReasoningEffort: "high", + color: "#DC2626", + providerRoute: "grok-acp", + providerModelId: "grok-4.6", + cliCommand: "grok", + isCliWrapped: true, + previewTier: true, + }, + { + id: "xai/grok-4-5", + shortId: "grok-4.5", + aliases: ["grok-4.5"], + displayName: "Grok 4.5", + family: "xai", + authTypes: ["cli-subscription"], + contextWindow: 500_000, + maxOutputTokens: 64_000, + capabilities: NO_REASONING, + reasoningTiers: ["low", "medium", "high"], + defaultReasoningEffort: "high", + color: "#B91C1C", + providerRoute: "grok-acp", + providerModelId: "grok-4.5", + cliCommand: "grok", + isCliWrapped: true, + previewTier: true, + }, + + // ---- GitHub Copilot (CLI-wrapped via `copilot`, ACP, preview) ---- + // Copilot dropped its fixed `--model` enum: the list is server-driven per + // account. These four are the durable picks; anything else the account can + // reach arrives through live discovery and is forwarded verbatim. + { + id: "github-copilot/claude-sonnet-4.6", + shortId: "copilot-sonnet-4.6", + aliases: ["copilot-sonnet"], + displayName: "Claude Sonnet 4.6 (Copilot)", + family: "github-copilot", + authTypes: ["cli-subscription"], + contextWindow: 200_000, + maxOutputTokens: 64_000, + capabilities: ALL_CAPS, + reasoningTiers: ["low", "medium", "high", "xhigh"], + color: "#8B5CF6", + providerRoute: "copilot-acp", + providerModelId: "claude-sonnet-4.6", + cliCommand: "copilot", + isCliWrapped: true, + previewTier: true, + }, + { + id: "github-copilot/claude-opus-4.6", + shortId: "copilot-opus-4.6", + aliases: ["copilot-opus"], + displayName: "Claude Opus 4.6 (Copilot)", + family: "github-copilot", + authTypes: ["cli-subscription"], + contextWindow: 200_000, + maxOutputTokens: 64_000, + capabilities: ALL_CAPS, + reasoningTiers: ["low", "medium", "high", "xhigh"], + color: "#7C3AED", + providerRoute: "copilot-acp", + providerModelId: "claude-opus-4.6", + cliCommand: "copilot", + isCliWrapped: true, + previewTier: true, + }, + { + id: "github-copilot/gpt-5.4", + shortId: "copilot-gpt-5.4", + aliases: ["copilot-gpt"], + displayName: "GPT-5.4 (Copilot)", + family: "github-copilot", + authTypes: ["cli-subscription"], + contextWindow: 400_000, + maxOutputTokens: 128_000, + capabilities: ALL_CAPS, + reasoningTiers: ["low", "medium", "high", "xhigh"], + color: "#6D28D9", + providerRoute: "copilot-acp", + providerModelId: "gpt-5.4", + cliCommand: "copilot", + isCliWrapped: true, + previewTier: true, + }, + { + id: "github-copilot/gpt-5.3-codex", + shortId: "copilot-gpt-5.3-codex", + aliases: ["copilot-codex"], + displayName: "GPT-5.3 Codex (Copilot)", + family: "github-copilot", + authTypes: ["cli-subscription"], + contextWindow: 400_000, + maxOutputTokens: 128_000, + capabilities: ALL_CAPS, + reasoningTiers: ["low", "medium", "high", "xhigh"], + color: "#5B21B6", + providerRoute: "copilot-acp", + providerModelId: "gpt-5.3-codex", + cliCommand: "copilot", + isCliWrapped: true, + previewTier: true, + }, + // ---- Cursor SDK models: discovered at runtime via @cursor/sdk (see cursorModelsDiscovery + getResolvedAvailableModels) ---- // ---- Local (Ollama) ---- @@ -636,6 +895,9 @@ let dynamicOpenCodeById = new Map(); let dynamicOpenCodeByAlias = new Map(); let dynamicPiById = new Map(); let dynamicPiByAlias = new Map(); +/** Live-discovered ACP models, one map per provider group. Curated rows win. */ +const dynamicAcpByProvider = new Map>(); +let dynamicAcpByAlias = new Map(); function rebuildIndexes() { byId = new Map(); @@ -1201,6 +1463,158 @@ export function getDynamicOpenCodeModelDescriptors(): ModelDescriptor[] { return [...dynamicOpenCodeById.values()]; } +// --------------------------------------------------------------------------- +// ACP providers: live-discovered models +// --------------------------------------------------------------------------- + +/** Provider groups whose models can arrive from a live ACP session. */ +export type AcpModelProviderGroup = "qwen" | "kimi" | "grok" | "copilot"; + +const ACP_MODEL_PROVIDER_GROUPS = ["qwen", "kimi", "grok", "copilot"] as const; + +/** Family, route prefix, and brand color for each ACP provider group. */ +const ACP_GROUP_METADATA: Record< + AcpModelProviderGroup, + { family: ProviderFamily; providerRoute: string; cliCommand: string; color: string; previewTier: boolean } +> = { + qwen: { family: "qwen", providerRoute: "qwen-acp", cliCommand: "qwen", color: "#6D4AFF", previewTier: false }, + kimi: { family: "moonshot", providerRoute: "kimi-acp", cliCommand: "kimi", color: "#1F1F1F", previewTier: false }, + grok: { family: "xai", providerRoute: "grok-acp", cliCommand: "grok", color: "#DC2626", previewTier: true }, + copilot: { + family: "github-copilot", + providerRoute: "copilot-acp", + cliCommand: "copilot", + color: "#8B5CF6", + previewTier: true, + }, +}; + +/** + * Cap on live rows per provider. + * + * A provider catalog crosses the sync wire to the phone. Shipping an unbounded + * discovered list is how 7238 OpenCode models once became a 4.85 MB payload, so + * discovery is bounded here rather than at each consumer. + */ +export const ACP_DYNAMIC_MODEL_LIMIT = 40; + +export function isAcpModelProviderGroup(value: string | null | undefined): value is AcpModelProviderGroup { + return value != null && (ACP_MODEL_PROVIDER_GROUPS as readonly string[]).includes(value); +} + +/** + * Stable ADE id for a discovered ACP model. + * + * The shape matches the curated rows exactly (`/`), so + * a model that later graduates into the curated table keeps its id and every + * chat that already picked it keeps working. + */ +export function acpRegistryIdFor(provider: AcpModelProviderGroup, providerModelId: string): string { + return `${ACP_GROUP_METADATA[provider].family}/${providerModelId.trim()}`; +} + +export type DynamicAcpModelDescriptorOptions = { + displayName?: string; + contextWindow?: number; + maxOutputTokens?: number; + capabilities?: Partial; + reasoningTiers?: string[]; + defaultReasoningEffort?: string; +}; + +/** + * Build a descriptor for a model an ACP agent reported. + * + * `providerModelId` is forwarded verbatim: it is what the dialect puts on the + * command line or into `session/set_config_option`, so rewriting it here would + * make the launch fail. + */ +export function createDynamicAcpModelDescriptor( + provider: AcpModelProviderGroup, + providerModelId: string, + options?: DynamicAcpModelDescriptorOptions, +): ModelDescriptor { + const metadata = ACP_GROUP_METADATA[provider]; + const modelId = providerModelId.trim(); + return { + id: acpRegistryIdFor(provider, modelId), + shortId: modelId, + displayName: options?.displayName?.trim() || formatOpenCodeDisplayName(modelId), + family: metadata.family, + authTypes: ["cli-subscription"], + contextWindow: options?.contextWindow ?? 200_000, + maxOutputTokens: options?.maxOutputTokens ?? 32_000, + capabilities: { + tools: options?.capabilities?.tools ?? true, + vision: options?.capabilities?.vision ?? false, + reasoning: options?.capabilities?.reasoning ?? false, + streaming: options?.capabilities?.streaming ?? true, + }, + ...(options?.reasoningTiers?.length ? { reasoningTiers: [...options.reasoningTiers] } : {}), + ...(options?.defaultReasoningEffort ? { defaultReasoningEffort: options.defaultReasoningEffort } : {}), + color: metadata.color, + providerRoute: metadata.providerRoute, + providerModelId: modelId, + cliCommand: metadata.cliCommand, + isCliWrapped: true, + ...(metadata.previewTier ? { previewTier: true } : {}), + }; +} + +/** + * Replace the discovered model list for one ACP provider. + * + * Whole-map replacement per provider, the same contract the OpenCode and Pi + * replacers use, plus two rules of its own: a curated id always wins + * (`byId.has`), and the list is capped so a chatty agent cannot inflate the + * catalog the phone has to download. + */ +export function replaceDynamicAcpModelDescriptors( + provider: AcpModelProviderGroup, + descriptors: ModelDescriptor[], +): void { + const next = new Map(); + const expectedRoute = ACP_GROUP_METADATA[provider].providerRoute; + for (const descriptor of descriptors) { + if (next.size >= ACP_DYNAMIC_MODEL_LIMIT) break; + if (descriptor.providerRoute !== expectedRoute) continue; + // A curated row is the researched one. Live discovery adds; it never + // shadows. + if (byId.has(descriptor.id)) continue; + next.set(descriptor.id, descriptor); + } + dynamicAcpByProvider.set(provider, next); + + dynamicAcpByAlias = new Map(); + for (const map of dynamicAcpByProvider.values()) { + for (const descriptor of map.values()) { + for (const alias of descriptor.aliases ?? []) { + const normalized = alias.trim().toLowerCase(); + if (normalized.length) dynamicAcpByAlias.set(normalized, descriptor); + } + } + } +} + +export function getDynamicAcpModelDescriptors(provider: AcpModelProviderGroup): ModelDescriptor[] { + return [...(dynamicAcpByProvider.get(provider)?.values() ?? [])]; +} + +export function mergeDynamicAcpModelDescriptors( + provider: AcpModelProviderGroup, + descriptors: ModelDescriptor[], +): void { + const existing = getDynamicAcpModelDescriptors(provider); + const incomingIds = new Set(descriptors.map((descriptor) => descriptor.id)); + const rest = existing.filter((descriptor) => !incomingIds.has(descriptor.id)); + replaceDynamicAcpModelDescriptors(provider, [...descriptors, ...rest]); +} + +export function clearDynamicAcpModelDescriptors(): void { + dynamicAcpByProvider.clear(); + dynamicAcpByAlias = new Map(); +} + export function getLocalProviderDefaultEndpoint(provider: LocalProviderFamily): string { return LOCAL_PROVIDER_ENDPOINTS[provider]; } @@ -1604,12 +2018,19 @@ export function getModelById(id: string): ModelDescriptor | undefined { const normalizedLower = normalized.toLowerCase(); const cached = byId.get(normalized) ?? byId.get(normalizedLower); if (cached) return cached; - const aliased = byAlias.get(normalizedLower) ?? dynamicOpenCodeByAlias.get(normalizedLower) ?? dynamicPiByAlias.get(normalizedLower); + const aliased = byAlias.get(normalizedLower) + ?? dynamicOpenCodeByAlias.get(normalizedLower) + ?? dynamicPiByAlias.get(normalizedLower) + ?? dynamicAcpByAlias.get(normalizedLower); if (aliased) return aliased; const dynamicOpenCode = dynamicOpenCodeById.get(normalized); if (dynamicOpenCode) return dynamicOpenCode; const dynamicPi = dynamicPiById.get(normalized); if (dynamicPi) return dynamicPi; + for (const map of dynamicAcpByProvider.values()) { + const dynamicAcp = map.get(normalized); + if (dynamicAcp) return dynamicAcp; + } const piDecoded = decodePiRegistryId(normalized); if (piDecoded) { return createDynamicPiModelDescriptor(piDecoded.providerId, piDecoded.modelId, { profileId: piDecoded.profileId }); @@ -1643,6 +2064,13 @@ export function getAvailableModels( google: "gemini", cursor: "cursor", factory: "droid", + // The ACP families. Without an entry each falls through to "any CLI + // subscription at all", which is how a Claude login would make Grok's + // models look available. + qwen: "qwen", + moonshot: "kimi", + xai: "grok", + "github-copilot": "copilot", }; const hasMappedCli = (family: ProviderFamily): boolean => { @@ -1708,6 +2136,7 @@ export function resolveModelAlias(alias: string): ModelDescriptor | undefined { ?? byAlias.get(normalized) ?? dynamicOpenCodeByAlias.get(normalized) ?? dynamicPiByAlias.get(normalized) + ?? dynamicAcpByAlias.get(normalized) ?? undefined; } @@ -1803,13 +2232,24 @@ export function resolveModelIdForProvider( return resolveModelDescriptorForProvider(modelRef, providerHint)?.id; } +/** + * The runtime that owns a descriptor's CLI. + * + * `isCliWrapped` is the gate, not the family: OpenCode routes xAI and Moonshot + * models too, and those descriptors share a family with the Grok and Kimi CLI + * rows. Only the CLI-wrapped ones belong to a provider runtime. + */ export function resolveCliProviderForModel( descriptor: ModelDescriptor, -): "claude" | "codex" | "cursor" | "droid" | "pi" | null { +): "claude" | "codex" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot" | null { if (descriptor.providerRoute === "pi-sdk") return "pi"; if (!descriptor.isCliWrapped) return null; if (descriptor.family === "cursor") return "cursor"; if (descriptor.family === "factory") return "droid"; + if (descriptor.family === "qwen") return "qwen"; + if (descriptor.family === "moonshot") return "kimi"; + if (descriptor.family === "xai") return "grok"; + if (descriptor.family === "github-copilot") return "copilot"; if (descriptor.family === "anthropic") return "claude"; if (descriptor.family === "openai") return "codex"; return null; @@ -1846,7 +2286,16 @@ export function getRuntimeModelRefForDescriptor( if (provider === "claude") { return descriptor.providerModelId; } - if (provider === "codex" || provider === "cursor" || provider === "droid" || provider === "pi") { + if ( + provider === "codex" + || provider === "cursor" + || provider === "droid" + || provider === "pi" + || provider === "qwen" + || provider === "kimi" + || provider === "grok" + || provider === "copilot" + ) { return descriptor.providerModelId; } return descriptor.id; @@ -1866,14 +2315,25 @@ export function classifyWorkerExecutionPath( function listProviderModelsInternal(provider: ModelProviderGroup): ModelDescriptor[] { if (provider === "pi") return getDynamicPiModelDescriptors(); - return MODEL_REGISTRY.filter((descriptor) => { + const curated = MODEL_REGISTRY.filter((descriptor) => { if (descriptor.deprecated) return false; if (provider === "claude") return descriptor.isCliWrapped && descriptor.family === "anthropic"; if (provider === "codex") return descriptor.isCliWrapped && descriptor.family === "openai"; if (provider === "cursor") return descriptor.family === "cursor"; if (provider === "droid") return descriptor.isCliWrapped && descriptor.family === "factory"; + if (provider === "qwen") return descriptor.isCliWrapped && descriptor.family === "qwen"; + if (provider === "kimi") return descriptor.isCliWrapped && descriptor.family === "moonshot"; + if (provider === "grok") return descriptor.isCliWrapped && descriptor.family === "xai"; + if (provider === "copilot") return descriptor.isCliWrapped && descriptor.family === "github-copilot"; return !descriptor.isCliWrapped; }); + // Curated rows first, then anything a live ACP session reported. The + // replacer already dropped ids the curated table owns, so this cannot + // duplicate a row. + if (isAcpModelProviderGroup(provider)) { + return [...curated, ...getDynamicAcpModelDescriptors(provider)]; + } + return curated; } function parseVersionSegments(value: string): number[] { @@ -1985,6 +2445,20 @@ function pickDefaultModelForProvider( if (provider === "cursor") return pickDefaultCursorDescriptorFromCliList(models); if (provider === "droid") return pickDefaultDroidDescriptorFromCliList(models); if (provider === "pi") return models[0]; + // The ACP providers ship a short curated list in registry order, so the first + // row is already the one ADE wants selected. Restating a preference here + // would be a second place to keep in step with the list itself. + if (provider === "qwen") { + // Custom models the Qwen CLI already has (a local OpenAI-compatible + // server, OpenRouter, DashScope) outrank the curated Alibaba rows, because + // those curated ids 502 on a proxy that does not serve them. + const discovered = getDynamicAcpModelDescriptors("qwen")[0]; + if (discovered) return models.find((model) => model.id === discovered.id) ?? models[0]; + return models[0]; + } + if (provider === "kimi" || provider === "grok" || provider === "copilot") { + return models[0]; + } return pickDefaultOpenCodeModel(models); } @@ -2003,6 +2477,18 @@ export function getDefaultModelDescriptor( return pickDefaultModelForProvider(provider, models); } +/** + * Is every model this provider offers a preview-tier one? + * + * The tier lives on the model descriptors, not on a provider table, so this is + * the single source Settings reads for its Preview chip — the catalog would + * answer the same question from a cached copy that can be a refresh behind. + */ +export function providerTierIsPreview(provider: ModelProviderGroup): boolean { + const models = listProviderModelsInternal(provider); + return models.length > 0 && models.every((model) => model.previewTier === true); +} + export function listModelDescriptorsForProvider( provider: ModelProviderGroup, ): ModelDescriptor[] { @@ -2012,6 +2498,29 @@ export function listModelDescriptorsForProvider( return [preferred, ...models.filter((model) => model.id !== preferred.id)]; } +/** + * List ACP models after applying a provider's own configured model list. + * + * Qwen's CLI can point at any OpenAI-compatible endpoint, so its + * `settings.json` is a stronger catalog than ADE's short Alibaba fallback + * rows. Dynamic rows reported by a live session remain visible; only curated + * rows are removed when the provider has explicitly configured models. + */ +export function listAcpModelDescriptorsForProvider( + provider: AcpModelProviderGroup, + options?: { configuredModelIds?: readonly string[] }, +): ModelDescriptor[] { + const models = listModelDescriptorsForProvider(provider); + const configuredIds = (options?.configuredModelIds ?? []) + .map((modelId) => modelId.trim()) + .filter(Boolean) + .map((modelId) => acpRegistryIdFor(provider, modelId)); + if (!configuredIds.length) return models; + + const configured = new Set(configuredIds); + return models.filter((model) => !byId.has(model.id) || configured.has(model.id)); +} + // --------------------------------------------------------------------------- // Runtime enrichment — mutate existing entries in-place with fresh data // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts b/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts index 2bacb34bd4..679e7f8378 100644 --- a/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts +++ b/apps/desktop/src/shared/orchestrationRuntimePolicy.test.ts @@ -28,6 +28,10 @@ const PROVIDER_PROFILE_EXPECTATIONS: Record { diff --git a/apps/desktop/src/shared/orchestrationRuntimePolicy.ts b/apps/desktop/src/shared/orchestrationRuntimePolicy.ts index 4563099b74..32532aa961 100644 --- a/apps/desktop/src/shared/orchestrationRuntimePolicy.ts +++ b/apps/desktop/src/shared/orchestrationRuntimePolicy.ts @@ -1,4 +1,5 @@ import type { + AgentChatAcpPermissionMode, AgentChatClaudePermissionMode, AgentChatCodexApprovalPolicy, AgentChatCodexConfigSource, @@ -31,6 +32,7 @@ export type OrchestrationPermissionProfile = Partial>; @@ -488,6 +490,17 @@ export function applyOrchestrationPermissionProfile( }; case "pi": return { permissionMode: "full-auto" }; + // Every ACP dialect maps ADE's abstract mode itself, so one entry covers + // the four. `yolo` is the ladder's top rung, which is what an orchestrated + // worker needs. + case "qwen": + case "kimi": + case "grok": + case "copilot": + return { + acpPermissionMode: "yolo" satisfies AgentChatAcpPermissionMode, + permissionMode: "full-auto", + }; default: return {}; } diff --git a/apps/desktop/src/shared/pendingInputLabels.ts b/apps/desktop/src/shared/pendingInputLabels.ts index 79a06b0340..237e87c1e5 100644 --- a/apps/desktop/src/shared/pendingInputLabels.ts +++ b/apps/desktop/src/shared/pendingInputLabels.ts @@ -12,6 +12,16 @@ const PROVIDER_DISPLAY_NAMES: Record = { factory: "Droid", opencode: "OpenCode", pi: "Pi", + qwen: "Qwen", + kimi: "Kimi", + moonshot: "Kimi", + grok: "Grok", + xai: "Grok", + copilot: "GitHub Copilot", + "github-copilot": "GitHub Copilot", + // Every ACP provider raises permissions through the same protocol method, so + // the shared source needs a name a person recognises rather than "Acp". + acp: "Agent", ade: "ADE", agent: "Agent", }; @@ -36,6 +46,10 @@ const CHAT_PROVIDER_DISPLAY_NAMES: Record = { droid: "Droid", opencode: "OpenCode", pi: "Pi", + qwen: "Qwen", + kimi: "Kimi", + grok: "Grok", + copilot: "GitHub Copilot", }; export function providerDisplayLabel( diff --git a/apps/desktop/src/shared/providerEnablement.test.ts b/apps/desktop/src/shared/providerEnablement.test.ts new file mode 100644 index 0000000000..ecc5264b92 --- /dev/null +++ b/apps/desktop/src/shared/providerEnablement.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + disabledProviderSet, + enabledProviderGroups, + isProviderDisabled, + toggleDisabledProvider, +} from "./providerEnablement"; + +describe("provider enablement", () => { + it("treats an absent or empty list as everything enabled", () => { + expect(isProviderDisabled(undefined, "grok")).toBe(false); + expect(isProviderDisabled({ disabledProviders: [] }, "grok")).toBe(false); + expect(enabledProviderGroups(null)).toContain("grok"); + }); + + it("matches ids case-insensitively and ignores blank entries", () => { + const ai = { disabledProviders: [" GROK ", "", " "] }; + expect(isProviderDisabled(ai, "grok")).toBe(true); + expect(isProviderDisabled(ai, "Grok")).toBe(true); + expect(isProviderDisabled(ai, "kimi")).toBe(false); + expect(disabledProviderSet(ai).size).toBe(1); + }); + + // The list crosses the sync wire. A machine on an older build must round-trip + // an id it does not recognise rather than dropping it, or toggling a provider + // off on one device would silently undo itself from another. + it("keeps ids it does not recognise when toggling", () => { + const ai = { disabledProviders: ["grok", "some-future-provider"] }; + expect(toggleDisabledProvider(ai, "grok", false)).toEqual(["some-future-provider"]); + expect(toggleDisabledProvider(ai, "kimi", true)).toEqual([ + "grok", + "some-future-provider", + "kimi", + ]); + }); + + it("does not duplicate an id that is already disabled", () => { + expect(toggleDisabledProvider({ disabledProviders: ["kimi"] }, "kimi", true)).toEqual(["kimi"]); + }); + + it("filters provider groups down to the ones still switched on", () => { + const enabled = enabledProviderGroups({ disabledProviders: ["grok", "copilot"] }); + expect(enabled).not.toContain("grok"); + expect(enabled).not.toContain("copilot"); + expect(enabled).toContain("qwen"); + expect(enabled).toContain("claude"); + }); +}); diff --git a/apps/desktop/src/shared/providerEnablement.ts b/apps/desktop/src/shared/providerEnablement.ts new file mode 100644 index 0000000000..54ed962dbd --- /dev/null +++ b/apps/desktop/src/shared/providerEnablement.ts @@ -0,0 +1,71 @@ +/** + * Which providers the user has switched off. + * + * Settings → Agents & Models gives every provider a toggle. Turning one off is + * a statement about the whole app, not about one picker, so the answer lives + * here and every surface that offers models asks the same question: + * + * - `getAvailableModels` drops the provider's rows, + * - the catalog ADE publishes to the phone and the relay drops them too, + * - the settings tile says "Disabled" and keeps its page reachable, because a + * switch you cannot find again is a one-way door. + * + * Entries are plain strings on purpose (see `AiConfig.disabledProviders`): the + * list crosses the sync wire and an older build must round-trip ids it does not + * know rather than silently dropping them. + */ + +import type { AiConfig } from "./types/config"; +import { MODEL_PROVIDER_GROUPS, type ModelProviderGroup } from "./modelRegistry"; + +/** Providers whose enablement Settings owns. Same set, same order, as the grid. */ +export const TOGGLEABLE_PROVIDERS = MODEL_PROVIDER_GROUPS; + +function normalize(id: string): string { + return id.trim().toLowerCase(); +} + +/** The disabled set, normalised. Unknown ids are kept: they belong to a newer build. */ +export function disabledProviderSet( + ai: Pick | null | undefined, +): ReadonlySet { + const entries = ai?.disabledProviders ?? []; + return new Set(entries.map(normalize).filter((id) => id.length > 0)); +} + +export function isProviderDisabled( + ai: Pick | null | undefined, + provider: string | null | undefined, +): boolean { + if (!provider) return false; + const disabled = ai?.disabledProviders; + if (!disabled?.length) return false; + return disabledProviderSet(ai).has(normalize(provider)); +} + +/** + * The next `disabledProviders` list after flipping one provider. + * + * Returns the whole authoritative list because that is what the config write + * path expects: `mergeAiConfig` replaces this field rather than unioning it, so + * a patch carrying only the delta would never be able to re-enable anything. + */ +export function toggleDisabledProvider( + ai: Pick | null | undefined, + provider: string, + disabled: boolean, +): string[] { + const next = new Set(disabledProviderSet(ai)); + if (disabled) next.add(normalize(provider)); + else next.delete(normalize(provider)); + return [...next]; +} + +/** Filter a provider-keyed list down to the providers still switched on. */ +export function enabledProviderGroups( + ai: Pick | null | undefined, + groups: readonly ModelProviderGroup[] = TOGGLEABLE_PROVIDERS, +): ModelProviderGroup[] { + const disabled = disabledProviderSet(ai); + return groups.filter((group) => !disabled.has(group)); +} diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 795826741f..fb194d4c52 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -14,7 +14,32 @@ import type { RuntimeProcessSummary } from "./sessions"; import type { SubagentCapability } from "../subagentCapabilities"; import { providerDisplayLabel } from "../pendingInputLabels"; -export type AgentChatProvider = "codex" | "claude" | "cursor" | "droid" | "opencode" | "pi" | (string & {}); +export type AgentChatProvider = + | "codex" + | "claude" + | "cursor" + | "droid" + | "opencode" + | "pi" + | "qwen" + | "kimi" + | "grok" + | "copilot" + | (string & {}); + +/** + * Providers ADE drives over the Agent Client Protocol. They share one host and + * one session-config shape, so surfaces branch on this list instead of naming + * the four providers again. + */ +export const ACP_CHAT_PROVIDERS = ["qwen", "kimi", "grok", "copilot"] as const; +export type AcpChatProvider = (typeof ACP_CHAT_PROVIDERS)[number]; + +export function isAcpChatProvider( + provider: AgentChatProvider | string | null | undefined, +): provider is AcpChatProvider { + return provider != null && (ACP_CHAT_PROVIDERS as readonly string[]).includes(provider); +} /** A completed model/provider transition recorded on a chat session. */ export type AgentChatModelHandoff = { @@ -1482,6 +1507,8 @@ export type AgentChatEvent = cursorModeId?: string | null; cursorModeSnapshot?: AgentChatCursorModeSnapshot; cursorConfigValues?: Record | null; + acpPermissionMode?: AgentChatAcpPermissionMode; + acpConfigSnapshot?: AgentChatAcpConfigSnapshot | null; spawnKind?: AgentChatSpawnKind; subagentTakeoverPromptShownAt?: string | null; // Accept turnId for uniformity with other variants — ignored by handlers. @@ -1626,7 +1653,55 @@ export type AgentChatCursorModeSnapshot = { availableModelIds?: string[]; configOptions?: AgentChatCursorConfigOption[]; }; -export type PendingInputSource = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi" | "ade"; +/** + * ACP session configuration, shared by every ACP provider. + * + * One shape, not four. The protocol exposes session settings through + * `session/set_config_option`, whose option list is opaque and provider-owned, + * so ADE records what a dialect advertised rather than modelling each vendor's + * vocabulary. Mirrors the Cursor snapshot pattern + * (`AgentChatCursorModeSnapshot` / `AgentChatCursorConfigValue`), which is the + * only other runtime whose session controls are discovered at runtime. + */ +export type AgentChatAcpConfigValue = string | boolean | number; +export type AgentChatAcpConfigSelectOption = { + value: string; + label: string; + description?: string | null; +}; +export type AgentChatAcpConfigOption = { + id: string; + name: string; + description?: string | null; + category?: string | null; + type: "select" | "boolean"; + currentValue: AgentChatAcpConfigValue | null; + options?: AgentChatAcpConfigSelectOption[]; +}; +export type AgentChatAcpConfigSnapshot = { + /** Provider-native session modes, when the dialect advertises them. */ + currentModeId?: string | null; + availableModeIds?: string[]; + /** Provider-native model ids, when the dialect advertises them. */ + currentModelId?: string | null; + availableModelIds?: string[]; + /** Everything else the dialect exposed, with its current value. */ + configOptions?: AgentChatAcpConfigOption[]; +}; + +/** + * The abstract permission posture ADE applies to an ACP session. Written once + * for all four providers; each dialect maps it to its own native flags (and + * rejects the modes it cannot honor) at the launch/spawn boundary. + */ +export type AgentChatAcpPermissionMode = "plan" | "default" | "auto-edit" | "auto" | "yolo"; + +/** + * `"acp"` is one source for all four ACP providers: the permission round-trip + * is `session/request_permission` in every dialect, so a card cannot tell them + * apart and does not need to. + */ +export type PendingInputSource = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi" | "acp" | "ade"; export type PendingInputKind = "approval" | "question" | "structured_question" | "permissions" | "plan_approval" | "model_selection"; export type PendingInputOption = { @@ -1731,6 +1806,8 @@ export type AgentChatSession = { * embedder learns "Pi ignored your servers" instead of assuming they landed. */ mcpCapability?: AgentChatMcpCapability; + acpPermissionMode?: AgentChatAcpPermissionMode; + acpConfigSnapshot?: AgentChatAcpConfigSnapshot | null; /** Durable Cursor Cloud agent id once this session has been promoted to cloud. */ cursorCloudAgentId?: string; /** Default runtime for new turns in this session (set on promotion). */ @@ -1801,6 +1878,8 @@ export type AgentChatSessionSummary = { strictMcpConfig?: boolean; /** What the provider could actually honor. Absent when no MCP was requested. */ mcpCapability?: AgentChatMcpCapability; + acpPermissionMode?: AgentChatAcpPermissionMode; + acpConfigSnapshot?: AgentChatAcpConfigSnapshot | null; cursorCloudAgentId?: string; cursorRuntime?: AgentChatRuntime; cursorPromotedTurnId?: string; @@ -2195,7 +2274,11 @@ export type AgentChatModelCatalogRefreshProvider = | "cursor" | "droid" | "lmstudio" - | "ollama"; + | "ollama" + | "qwen" + | "kimi" + | "grok" + | "copilot"; export type AgentChatModelCatalogMode = "cached" | "refresh-stale" | "force"; @@ -2298,6 +2381,8 @@ export type AgentChatCreateArgs = { droidPermissionMode?: AgentChatDroidPermissionMode; cursorModeId?: string | null; cursorConfigValues?: Record | null; + acpPermissionMode?: AgentChatAcpPermissionMode; + acpConfigSnapshot?: AgentChatAcpConfigSnapshot | null; identityKey?: AgentChatIdentityKey; surface?: AgentChatSurface; automationId?: string | null; @@ -2411,7 +2496,11 @@ export type AgentChatCliLaunchProvider = | "cursor" | "droid" | "opencode" - | "pi"; + | "pi" + | "qwen" + | "kimi" + | "grok" + | "copilot"; /** * Launch a tracked CLI/terminal agent (not the in-process chat SDK) with one or @@ -2463,6 +2552,12 @@ export type AgentChatRuntimeMode = "interactive" | "print"; * replayed into it verbatim (bounded by the target model's context window), * the same replay used when an agent rotates. Fork requires source and target * on the same provider; the model may still change within that provider. + * + * The ACP providers (qwen, kimi, grok, copilot) are deliberately absent. ACP + * has no fork method, and ADE cannot replay a transcript into an ACP session + * without a per-dialect load/resume path it does not yet have, so a handoff + * from one of them is brief-only. Adding one here would promise a copied thread + * that no dialect can produce. */ export const HANDOFF_FORK_PROVIDERS = ["claude", "codex", "opencode", "droid", "cursor"] as const; @@ -2528,6 +2623,8 @@ export type AgentChatHandoffArgs = { permissionMode?: AgentChatPermissionMode; cursorModeId?: string | null; cursorConfigValues?: Record | null; + acpPermissionMode?: AgentChatAcpPermissionMode; + acpConfigSnapshot?: AgentChatAcpConfigSnapshot | null; }; export type AgentChatHandoffResult = { @@ -2561,6 +2658,8 @@ export type AgentChatCrossMachineTargetConfig = { permissionMode?: AgentChatPermissionMode; cursorModeId?: string | null; cursorConfigValues?: Record | null; + acpPermissionMode?: AgentChatAcpPermissionMode; + acpConfigSnapshot?: AgentChatAcpConfigSnapshot | null; }; export type AgentChatCrossMachineHandoffCapsule = { @@ -2831,10 +2930,18 @@ export type ActiveTurnSendMode = "queue" | AgentChatDispatchSteerMode; * Claude folds a message into the live query, so it has all three. Cursor's SDK * has no mid-run message API: its interrupt cancels the run and resends on the * same agent thread, so it has no "inline". Everything else is queue-only. + * + * The ACP providers are stated rather than left to the fallback. ACP has no + * mid-turn message method at all — `session/prompt` is one request per turn — + * so queue-only is a protocol fact, not a gap waiting to be filled. */ export const ACTIVE_TURN_DISPATCH_MODES: Partial> = { claude: ["inline", "queue", "interrupt"], cursor: ["interrupt", "queue"], + qwen: ["queue"], + kimi: ["queue"], + grok: ["queue"], + copilot: ["queue"], }; const QUEUE_ONLY_ACTIVE_TURN_MODES: readonly ActiveTurnSendMode[] = ["queue"]; @@ -3274,6 +3381,8 @@ export type AgentChatUpdateSessionArgs = { droidPermissionMode?: AgentChatDroidPermissionMode; cursorModeId?: string | null; cursorConfigValues?: Record | null; + acpPermissionMode?: AgentChatAcpPermissionMode; + acpConfigSnapshot?: AgentChatAcpConfigSnapshot | null; }; export const AGENT_CHAT_SESSION_METADATA_FIELDS = ["title", "laneName", "statusLine"] as const; diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 09176117e1..6e04870cbf 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -961,7 +961,7 @@ export type AiFeatureUsageRow = { export type AiDetectedAuth = { type: "cli-subscription" | "api-key" | "oauth" | "openrouter" | "local"; - cli?: "claude" | "codex" | "cursor" | "droid"; + cli?: "claude" | "codex" | "cursor" | "droid" | "qwen" | "kimi" | "grok" | "copilot"; provider?: string; source?: "config" | "env" | "store" | "file"; endpointSource?: "auto" | "config"; @@ -995,7 +995,7 @@ export type AiProviderConnectionSource = { }; export type AiProviderConnectionStatus = { - provider: "claude" | "codex" | "cursor" | "droid" | "pi"; + provider: "claude" | "codex" | "cursor" | "droid" | "pi" | "qwen" | "kimi" | "grok" | "copilot"; authAvailable: boolean; runtimeDetected: boolean; runtimeAvailable: boolean; @@ -1014,6 +1014,36 @@ export type AiProviderConnections = { cursor: AiProviderConnectionStatus; droid: AiProviderConnectionStatus; pi?: AiProviderConnectionStatus; + // ACP providers. Optional like `pi`, because an older host on the other end + // of a cross-machine call has no arm for them and must keep deserialising. + qwen?: AiProviderConnectionStatus; + kimi?: AiProviderConnectionStatus; + grok?: AiProviderConnectionStatus; + copilot?: AiProviderConnectionStatus; +}; + +/** + * What Settings knows about one ACP provider CLI beyond its connection status. + * + * Collected on demand (a detail page opening, or "Run doctor"), never on a + * status refresh: every field below either costs a process spawn or is only + * meaningful next to one that does. + */ +export type AcpProviderDiagnostics = { + provider: "qwen" | "kimi" | "grok" | "copilot"; + /** Null when nothing was found — the bare command name is a guess, not a path. */ + binaryPath: string | null; + binarySource: "env" | "auth" | "path" | "common-dir" | "fallback-command"; + /** Directory the CLI reads its config from. Grok's is fixed at `~/.grok`. */ + configHome: string | null; + version: string | null; + /** Why no version, when there is none. */ + versionError: string | null; + /** The last verdict `acpAuthProbe` cached for this provider and directory. */ + lastProbe: { state: "ready" | "auth-failed" | "runtime-failed"; message: string | null } | null; + /** Present only when the vendor ships a `doctor` command and it was run. */ + doctor: { command: string; exitCode: number | null; output: string } | null; + checkedAt: string; }; export type AiApiKeyVerificationResult = { @@ -1508,12 +1538,20 @@ export type AiSettingsStatus = { codex: boolean; cursor: boolean; droid: boolean; + qwen?: boolean; + kimi?: boolean; + grok?: boolean; + copilot?: boolean; }; models: { claude: AiModelDescriptor[]; codex: AiModelDescriptor[]; cursor: AiModelDescriptor[]; droid: AiModelDescriptor[]; + qwen?: AiModelDescriptor[]; + kimi?: AiModelDescriptor[]; + grok?: AiModelDescriptor[]; + copilot?: AiModelDescriptor[]; }; features: AiFeatureUsageRow[]; detectedAuth?: AiDetectedAuth[]; @@ -1576,6 +1614,12 @@ export type AiProviderPermissions = { droid?: AgentChatPermissionMode; opencode?: AgentChatPermissionMode; pi?: AgentChatPermissionMode; + // The ACP providers. One key each, sharing one option list, because they + // share one permission round-trip (`session/request_permission`). + qwen?: AgentChatPermissionMode; + kimi?: AgentChatPermissionMode; + grok?: AgentChatPermissionMode; + copilot?: AgentChatPermissionMode; codexSandbox?: "read-only" | "workspace-write" | "danger-full-access"; writablePaths?: string[]; allowedTools?: string[]; @@ -1705,6 +1749,19 @@ export type AiConfig = { customProviders?: AiCustomProviderConfig[]; /** Extra model slugs (provider/model) the user pinned as selectable beyond probed inventory. */ customModelSlugs?: string[]; + /** + * Providers the user switched off in Settings → Agents & Models. + * + * A disabled provider still has a settings page — that is where the switch + * lives, and a one-way door is a bug — but it offers no models anywhere else: + * not in a picker, not in the catalog ADE publishes to the phone, not to a + * chat that tries to start on it. + * + * Deliberately `string[]` rather than a union of today's ten ids: this list + * crosses the sync wire, and a machine running an older build must be able to + * read a newer one's list without losing the entries it does not recognise. + */ + disabledProviders?: string[]; workerSafety?: WorkerSafetyPolicy; /** Per-feature model overrides, e.g. { pr_descriptions: "claude-sonnet-5" } */ featureModelOverrides?: Partial>; @@ -1729,12 +1786,20 @@ export type AiIntegrationStatus = { codex: boolean; cursor: boolean; droid: boolean; + qwen?: boolean; + kimi?: boolean; + grok?: boolean; + copilot?: boolean; }; models: { claude: AgentChatModelInfo[]; codex: AgentChatModelInfo[]; cursor: AgentChatModelInfo[]; droid: AgentChatModelInfo[]; + qwen?: AgentChatModelInfo[]; + kimi?: AgentChatModelInfo[]; + grok?: AgentChatModelInfo[]; + copilot?: AgentChatModelInfo[]; }; // OpenCode/runtime-backed fields detectedAuth?: AiDetectedAuth[]; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index f64c012892..b7151d3be8 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -49,6 +49,15 @@ export type TerminalToolType = | "cursor" | "droid-chat" | "pi-chat" + // ACP providers: tracked CLI terminal, then the ADE chat runtime. + | "qwen" + | "kimi" + | "grok" + | "copilot" + | "qwen-chat" + | "kimi-chat" + | "grok-chat" + | "copilot-chat" | "aider" | "continue" | "other"; @@ -60,6 +69,10 @@ export type TrackedAgentCliToolType = | "droid" | "opencode" | "pi" + | "qwen" + | "kimi" + | "grok" + | "copilot" | "claude-orchestrated" | "codex-orchestrated" | "opencode-orchestrated"; @@ -74,6 +87,19 @@ export function isPtySendPreDeliveryError( && error.code === PTY_SEND_PRE_DELIVERY_ERROR_CODE; } +/** + * Is this terminal running an agent CLI ADE tracks? + * + * "Tracked" buys the session the whole agent-CLI apparatus: TUI turn markers, + * resume-target capture, scheduled turns, orchestration lineage — and two + * safety behaviours that are easy to miss, because both are about what + * *stops*. A tracked launch is refused when the disk is exhausted, and a + * tracked session's built-in-browser actor token is revoked when it closes. + * The token is issued unconditionally, so a tool type missing from this list + * gets a capability that outlives its terminal. + * + * Must stay in step with `TrackedAgentCliToolType` above. + */ export function isTrackedAgentCliToolType( toolType: TerminalToolType | null | undefined, ): toolType is TrackedAgentCliToolType { @@ -83,6 +109,10 @@ export function isTrackedAgentCliToolType( || toolType === "droid" || toolType === "opencode" || toolType === "pi" + || toolType === "qwen" + || toolType === "kimi" + || toolType === "grok" + || toolType === "copilot" || toolType === "claude-orchestrated" || toolType === "codex-orchestrated" || toolType === "opencode-orchestrated"; @@ -138,7 +168,17 @@ export function parseSessionSettleOverride( return undefined; } -export type TerminalResumeProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi"; +export type TerminalResumeProvider = + | "claude" + | "codex" + | "cursor" + | "droid" + | "opencode" + | "pi" + | "qwen" + | "kimi" + | "grok" + | "copilot"; export type TerminalResumeTargetKind = "session" | "thread"; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index a9b5858c44..fde9e03a79 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1691,7 +1691,18 @@ export type SyncRunQuickCommandArgs = { tracked?: boolean; }; -export type SyncCliLaunchProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | "pi" | "shell"; +export type SyncCliLaunchProvider = + | "claude" + | "codex" + | "cursor" + | "droid" + | "opencode" + | "pi" + | "qwen" + | "kimi" + | "grok" + | "copilot" + | "shell"; export type SyncStartCliSessionArgs = { laneId: string; diff --git a/apps/ios/ADE/Assets.xcassets/ProviderKimi.imageset/Contents.json b/apps/ios/ADE/Assets.xcassets/ProviderKimi.imageset/Contents.json new file mode 100644 index 0000000000..97c4d58220 --- /dev/null +++ b/apps/ios/ADE/Assets.xcassets/ProviderKimi.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "kimi.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/apps/ios/ADE/Assets.xcassets/ProviderKimi.imageset/kimi.svg b/apps/ios/ADE/Assets.xcassets/ProviderKimi.imageset/kimi.svg new file mode 100644 index 0000000000..03228320b2 --- /dev/null +++ b/apps/ios/ADE/Assets.xcassets/ProviderKimi.imageset/kimi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ADE/Assets.xcassets/ProviderQwen.imageset/Contents.json b/apps/ios/ADE/Assets.xcassets/ProviderQwen.imageset/Contents.json new file mode 100644 index 0000000000..3e80746854 --- /dev/null +++ b/apps/ios/ADE/Assets.xcassets/ProviderQwen.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "qwen.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/apps/ios/ADE/Assets.xcassets/ProviderQwen.imageset/qwen.svg b/apps/ios/ADE/Assets.xcassets/ProviderQwen.imageset/qwen.svg new file mode 100644 index 0000000000..ec97e93d6a --- /dev/null +++ b/apps/ios/ADE/Assets.xcassets/ProviderQwen.imageset/qwen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ADE/Assets.xcassets/ProviderXAI.imageset/Contents.json b/apps/ios/ADE/Assets.xcassets/ProviderXAI.imageset/Contents.json new file mode 100644 index 0000000000..1c0c6b4718 --- /dev/null +++ b/apps/ios/ADE/Assets.xcassets/ProviderXAI.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "xai.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/apps/ios/ADE/Assets.xcassets/ProviderXAI.imageset/xai.svg b/apps/ios/ADE/Assets.xcassets/ProviderXAI.imageset/xai.svg new file mode 100644 index 0000000000..07d9da49e6 --- /dev/null +++ b/apps/ios/ADE/Assets.xcassets/ProviderXAI.imageset/xai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/ADE/Shared/ADESharedTheme.swift b/apps/ios/ADE/Shared/ADESharedTheme.swift index 74a6bd17ec..410521a0ad 100644 --- a/apps/ios/ADE/Shared/ADESharedTheme.swift +++ b/apps/ios/ADE/Shared/ADESharedTheme.swift @@ -17,6 +17,12 @@ public enum ADESharedTheme { public static let brandXAI = Color(red: 0xDC / 255.0, green: 0x26 / 255.0, blue: 0x26 / 255.0) // #DC2626 public static let brandGroq = Color(red: 0x06 / 255.0, green: 0xB6 / 255.0, blue: 0xD4 / 255.0) // #06B6D4 public static let brandCTO = Color(red: 0xC4 / 255.0, green: 0xB5 / 255.0, blue: 0xFD / 255.0) // #C4B5FD + // ACP providers. Hexes mirror PROVIDER_GROUP_COLORS in the desktop's + // `shared/modelCatalog.ts`; Grok reuses brandXAI, which already carries + // that vendor's #DC2626. + public static let brandQwen = Color(red: 0x6D / 255.0, green: 0x4A / 255.0, blue: 0xFF / 255.0) // #6D4AFF + public static let brandKimi = Color(red: 0x1F / 255.0, green: 0x1F / 255.0, blue: 0x1F / 255.0) // #1F1F1F + public static let brandCopilot = Color(red: 0x8B / 255.0, green: 0x5C / 255.0, blue: 0xF6 / 255.0) // #8B5CF6 /// Neutral fallback when the provider slug is unknown. Keeps parity with /// `ADEColor.purpleAccent` in the main design system. @@ -61,7 +67,13 @@ public enum ADESharedTheme { if raw.hasPrefix("cursor") { return "cursor" } if raw == "factory" || raw.hasPrefix("droid") { return "droid" } if raw == "gemini" || raw.hasPrefix("google") { return "google" } + // Copilot before the bare `github` fold: `github-copilot` is the ACP + // provider, not the GitHub integration that fold was written for. + if raw == "github-copilot" || raw == "githubcopilot" || raw.hasPrefix("copilot") { return "copilot" } if raw.hasPrefix("github") { return "github" } + if raw.hasPrefix("qwen") { return "qwen" } + if raw == "moonshot" || raw == "moonshotai" || raw.hasPrefix("kimi") { return "kimi" } + if raw.hasPrefix("grok") { return "grok" } return raw } @@ -82,6 +94,9 @@ public enum ADESharedTheme { case "xai", "grok": return brandXAI case "groq": return brandGroq case "cto": return brandCTO + case "qwen": return brandQwen + case "kimi": return brandKimi + case "copilot": return brandCopilot default: return neutralAccent } } @@ -105,6 +120,11 @@ public enum ADESharedTheme { case "droid", "factory": return "ProviderDroid" case "pi": return nil case "github": return "ProviderGitHub" + case "qwen": return "ProviderQwen" + case "kimi": return "ProviderKimi" + case "grok", "xai": return "ProviderXAI" + // GitHub's own mark, already bundled for the GitHub surfaces. + case "copilot": return "ProviderGitHub" default: return nil } } @@ -131,6 +151,9 @@ public enum ADESharedTheme { case "groq": return "Groq" case "cto": return "CTO" case "github": return "GitHub" + case "qwen": return "Qwen" + case "kimi": return "Kimi" + case "copilot": return "GitHub Copilot" case "ade": return "ADE" default: let value = providerSlug.trimmingCharacters(in: .whitespacesAndNewlines) @@ -144,6 +167,13 @@ public enum ADESharedTheme { public static func providerSlug(forModel model: String?) -> String? { guard let model else { return nil } let value = model.lowercased() + // ACP registry ids are namespaced and go first: `github-copilot/...` + // ids name the upstream vendor after the slash, so the vendor checks + // below would otherwise claim them. + if value.hasPrefix("github-copilot/") { return "copilot" } + if value.hasPrefix("qwen/") || value.contains("qwen") { return "qwen" } + if value.hasPrefix("moonshot/") || value.contains("kimi") { return "kimi" } + if value.hasPrefix("xai/") || value.contains("grok") { return "grok" } if value.contains("claude") || value.contains("anthropic") { return "claude" } if value.contains("codex") { return "codex" } if value.contains("gpt") || value.contains("openai") || value.hasPrefix("o3") || value.hasPrefix("o4") { diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 74f70146d9..4ab263cfc1 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -130,6 +130,9 @@ enum ADEColor { case "deepseek": return brandDeepSeek case "xai", "grok": return brandXAI case "groq": return brandGroq + case "qwen": return ADESharedTheme.brandQwen + case "kimi", "moonshot", "moonshotai": return ADESharedTheme.brandKimi + case "copilot", "github-copilot": return ADESharedTheme.brandCopilot default: return purpleAccent } } @@ -385,6 +388,11 @@ enum ADEColor { "xai": 0xDC2626, "grok": 0xDC2626, "groq": 0x06B6D4, + "qwen": 0x6D4AFF, + "kimi": 0x1F1F1F, + "moonshot": 0x1F1F1F, + "copilot": 0x8B5CF6, + "github-copilot": 0x8B5CF6, ] /// Resolve the chat-surface accent for a session. Precedence: explicit hex diff --git a/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift b/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift index 6c7b9dfe93..d757b96660 100644 --- a/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift +++ b/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift @@ -56,6 +56,10 @@ struct WorkContextCompactDivider: View { case "opencode": return .cyan case "droid": return .orange case "pi": return .orange + case "qwen": return .purple + case "kimi": return .primary + case "grok": return .red + case "copilot": return .cyan default: return ADEColor.warning } } diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 5b84e507e4..e503366230 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -206,6 +206,10 @@ func workResolveCliProvider(for modelId: String, provider: String) -> String { case "cursor": return "cursor" case "droid": return "droid" case "pi": return "pi" + case "qwen": return "qwen" + case "kimi": return "kimi" + case "grok": return "grok" + case "copilot": return "copilot" default: return "opencode" } } @@ -272,12 +276,12 @@ struct WorkModelProvider: Identifiable, Hashable { let models: [WorkModelOption] } -/// Top-level catalog group: one of CLAUDE / CODEX / CURSOR / OPENCODE. This -/// drives the first-level tab strip in the mobile picker. Exactly mirrors -/// the desktop `ModelCatalogPanel` group layout. +/// Top-level catalog group: one of the runtime/provider keys listed in +/// `workModelGroupOrder`. This drives the first-level tab strip in the mobile +/// picker and mirrors the desktop `ModelCatalogPanel` group layout. struct WorkModelCatalogGroup: Identifiable, Hashable { var id: String { key } - /// Runtime key: "claude" | "codex" | "cursor" | "droid" | "pi" | "opencode". + /// Runtime key such as "claude", "codex", "cursor", or "opencode". let key: String let displayName: String let providers: [WorkModelProvider] @@ -299,7 +303,42 @@ struct WorkModelCatalogGroupLegacyView: Identifiable, Hashable { let models: [WorkModelOption] } -private let workModelGroupOrder = ["claude", "codex", "pi", "cursor", "droid", "opencode", "ollama", "lmstudio"] +/// Group keys, in the order the picker lists them. A group missing from this +/// list is dropped from the phone's catalog entirely, so it must carry every +/// `ModelProviderGroup` the host can publish (see `MODEL_PROVIDER_GROUPS` in +/// `apps/desktop/src/shared/modelRegistry.ts`) plus the two OpenCode-routed +/// local groups. +private let workModelGroupOrder = [ + "claude", + "codex", + "cursor", + "opencode", + "pi", + "copilot", + "grok", + "droid", + "kimi", + "qwen", + "ollama", + "lmstudio", +] + +private func workModelGroupComesBefore(_ lhs: String, _ rhs: String) -> Bool { + let lhsOrder = workModelGroupOrder.firstIndex(of: lhs) ?? workModelGroupOrder.count + let rhsOrder = workModelGroupOrder.firstIndex(of: rhs) ?? workModelGroupOrder.count + if lhsOrder != rhsOrder { return lhsOrder < rhsOrder } + return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending +} + +private func workOrderedModelCatalogGroups(_ groups: [WorkModelCatalogGroup]) -> [WorkModelCatalogGroup] { + groups.sorted { workModelGroupComesBefore($0.key, $1.key) } +} + +private func workOrderedAgentChatModelCatalogGroups( + _ groups: [AgentChatModelCatalogGroup] +) -> [AgentChatModelCatalogGroup] { + groups.sorted { workModelGroupComesBefore($0.key, $1.key) } +} private func workClaudeFableReasoningEfforts() -> [AgentChatModelReasoningEffort] { workClaudeOpus5ReasoningEfforts() + [ @@ -675,7 +714,7 @@ private func workCuratedModelCatalogGroups() -> [WorkModelCatalogGroup] { ] )) - return groups + return workOrderedModelCatalogGroups(groups) } /// Curated catalog with the current live model injected when needed. @@ -753,7 +792,7 @@ func workModelCatalogGroups( currentModelId: String, currentProvider: String ) -> [WorkModelCatalogGroup] { - let groups = hostCatalog.groups.map { group in + let groups = workOrderedAgentChatModelCatalogGroups(hostCatalog.groups).map { group in let isPiGroup = group.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "pi" var providers: [WorkModelProvider] if isPiGroup { @@ -1228,6 +1267,10 @@ private func workProviderDisplayName( case "together": return "Together" case "cursor": return "Cursor" case "factory": return "Droid Core" + case "qwen": return "Qwen" + case "moonshot", "moonshotai", "kimi": return "Kimi" + case "grok": return "Grok" + case "copilot", "github-copilot": return "GitHub Copilot" default: return providerKey.capitalized } } @@ -1454,6 +1497,19 @@ private func workModelProviderKey(for model: AgentChatModelInfo, topLevelProvide return "xai" } return normalizedFamily.isEmpty ? "cursor" : normalizedFamily + case "copilot": + // Copilot resells other vendors' models, so split it by upstream brand the + // way Cursor and Droid are split rather than showing one flat list. + if normalizedId.contains("claude") || normalizedId.contains("sonnet") || normalizedId.contains("opus") || normalizedId.contains("haiku") { + return "anthropic" + } + if normalizedId.contains("gpt") || normalizedId.contains("codex") { + return "openai" + } + if normalizedId.contains("gemini") { + return "google" + } + return "github-copilot" default: return topLevelProvider } @@ -1683,7 +1739,7 @@ private func injectCurrentWorkModelIfNeeded( } } - return groups + return workOrderedModelCatalogGroups(groups) } func workModelCatalogGroupKey(for currentModelId: String, currentProvider: String) -> String { @@ -1711,6 +1767,23 @@ func workModelCatalogGroupKey(for currentModelId: String, currentProvider: Strin if modelId.hasPrefix("opencode/") || provider == "opencode" { return "opencode" } + // ACP providers, before the model-name heuristics below. Their registry ids + // carry the upstream vendor — `github-copilot/claude-sonnet-4.6` contains + // "claude", `github-copilot/gpt-5.4` contains "gpt" — so checking them later + // would file a Copilot model under Claude or Codex. The OpenCode checks stay + // ahead of these, because an OpenCode-routed Kimi or Grok belongs to OpenCode. + if provider == "qwen" || modelId.hasPrefix("qwen/") { + return "qwen" + } + if provider == "kimi" || provider == "moonshot" || modelId.hasPrefix("moonshot/") { + return "kimi" + } + if provider == "grok" || modelId.hasPrefix("xai/") { + return "grok" + } + if provider == "copilot" || provider == "github-copilot" || modelId.hasPrefix("github-copilot/") { + return "copilot" + } if workCanonicalCodexRegistryId(for: modelId) != nil { return "codex" } diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index 33168b522a..00144f451b 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -528,7 +528,8 @@ struct WorkModelPickerSheet: View { private func refreshCatalog(for groupKey: String) async { let refreshProvider: String? switch groupKey { - case "opencode", "cursor", "droid", "pi", "lmstudio", "ollama": + case "opencode", "cursor", "droid", "pi", "lmstudio", "ollama", + "qwen", "kimi", "grok", "copilot": refreshProvider = groupKey default: refreshProvider = nil diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 846385914b..a4128c447d 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -240,6 +240,10 @@ func workChatSurfaceProviderName(_ source: String?) -> String { case "droid", "factory": return "Droid" case "opencode": return "OpenCode" case "pi": return "Pi" + case "qwen": return "Qwen" + case "kimi", "moonshot": return "Kimi" + case "grok", "xai": return "Grok" + case "copilot", "github-copilot": return "Copilot" case "ade": return "ADE" default: return raw @@ -442,6 +446,18 @@ struct WorkActiveSendCapability: Equatable { return WorkActiveSendCapability(modes: [.inline, .queue, .interrupt], agentLabel: "Claude", interruptContinues: false) case "cursor": return WorkActiveSendCapability(modes: [.interrupt, .queue], agentLabel: "Cursor", interruptContinues: true) + // The four ACP providers are queue-only in `ACTIVE_TURN_DISPATCH_MODES`, + // which is what the default arm already gives them. They are listed anyway + // so the label reads with the provider's name instead of "the agent", and + // so the next person diffing this table against the TS one sees them here. + case "qwen": + return WorkActiveSendCapability(modes: [.queue], agentLabel: "Qwen", interruptContinues: false) + case "kimi": + return WorkActiveSendCapability(modes: [.queue], agentLabel: "Kimi", interruptContinues: false) + case "grok": + return WorkActiveSendCapability(modes: [.queue], agentLabel: "Grok", interruptContinues: false) + case "copilot": + return WorkActiveSendCapability(modes: [.queue], agentLabel: "GitHub Copilot", interruptContinues: false) default: return WorkActiveSendCapability(modes: [.queue], agentLabel: "the agent", interruptContinues: false) } diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 5932c5f00f..61d9be6bb3 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -1378,6 +1378,10 @@ private func workCliToolType(provider: String) -> String { case "opencode": return "opencode" case "pi": return "pi" case "droid": return "droid" + case "qwen": return "qwen" + case "kimi": return "kimi" + case "grok": return "grok" + case "copilot": return "copilot" case "shell": return "shell" default: return "opencode" } diff --git a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift index a564b3464f..b318772fdf 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift @@ -66,6 +66,20 @@ struct WorkNewChatSheet: View { icon: providerIcon("codex"), tint: providerTint("codex") ), + WorkProviderOption( + id: "cursor", + title: "Cursor", + subtitle: "Cursor-native chat sessions", + icon: providerIcon("cursor"), + tint: providerTint("cursor") + ), + WorkProviderOption( + id: "opencode", + title: "OpenCode", + subtitle: "Open workflows and tools", + icon: providerIcon("opencode"), + tint: providerTint("opencode") + ), WorkProviderOption( id: "pi", title: "Pi", @@ -74,11 +88,18 @@ struct WorkNewChatSheet: View { tint: providerTint("pi") ), WorkProviderOption( - id: "cursor", - title: "Cursor", - subtitle: "Cursor-native chat sessions", - icon: providerIcon("cursor"), - tint: providerTint("cursor") + id: "copilot", + title: "GitHub Copilot", + subtitle: "Copilot CLI on the paired Mac", + icon: providerIcon("copilot"), + tint: providerTint("copilot") + ), + WorkProviderOption( + id: "grok", + title: "Grok", + subtitle: "Grok CLI on the paired Mac", + icon: providerIcon("grok"), + tint: providerTint("grok") ), WorkProviderOption( id: "droid", @@ -88,11 +109,18 @@ struct WorkNewChatSheet: View { tint: providerTint("droid") ), WorkProviderOption( - id: "opencode", - title: "OpenCode", - subtitle: "Open workflows and tools", - icon: providerIcon("opencode"), - tint: providerTint("opencode") + id: "kimi", + title: "Kimi", + subtitle: "Kimi Code on the paired Mac", + icon: providerIcon("kimi"), + tint: providerTint("kimi") + ), + WorkProviderOption( + id: "qwen", + title: "Qwen", + subtitle: "Qwen Code on the paired Mac", + icon: providerIcon("qwen"), + tint: providerTint("qwen") ), ] } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index 8705be756f..9af5e135f9 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -919,6 +919,10 @@ private func cliProviderForTerminalSession(_ session: TerminalSessionSummary) -> if toolType.hasPrefix("cursor") { return "cursor" } if toolType.hasPrefix("droid") { return "droid" } if toolType.hasPrefix("opencode") { return "opencode" } + if toolType.hasPrefix("qwen") { return "qwen" } + if toolType.hasPrefix("kimi") { return "kimi" } + if toolType.hasPrefix("grok") { return "grok" } + if toolType.hasPrefix("copilot") { return "copilot" } return "shell" } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index b036fe61e9..987b1d151d 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -634,6 +634,10 @@ private func workChatProviderFamilyFromToolType(_ toolType: String?) -> String? if raw.hasPrefix("codex") { return "codex" } if raw.hasPrefix("opencode") { return "opencode" } if raw.hasPrefix("droid") || raw.hasPrefix("factory") { return "droid" } + if raw.hasPrefix("qwen") { return "qwen" } + if raw.hasPrefix("kimi") { return "kimi" } + if raw.hasPrefix("grok") { return "grok" } + if raw.hasPrefix("copilot") { return "copilot" } return raw } diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index ed661a405f..5bf724f7b6 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -271,6 +271,10 @@ func providerLabel(_ provider: String) -> String { case "google": return "Google" case "ollama": return "Ollama" case "lmstudio": return "LM Studio" + case "qwen": return "Qwen" + case "kimi": return "Kimi" + case "grok": return "Grok" + case "copilot": return "GitHub Copilot" default: return provider.capitalized } } @@ -328,6 +332,14 @@ func providerIcon(_ provider: String) -> String { return "desktopcomputer" case "google": return "g.circle.fill" + case "qwen": + return "square.on.square" + case "kimi": + return "moon.stars.fill" + case "grok": + return "bolt.fill" + case "copilot": + return "chevron.left.forwardslash.chevron.right" default: return "brain.head.profile" } @@ -356,6 +368,15 @@ func providerAssetName(_ provider: String?) -> String? { return "ProviderOpenCode" case "droid", "factory": return "ProviderDroid" + case "qwen": + return "ProviderQwen" + case "kimi": + return "ProviderKimi" + case "grok": + return "ProviderXAI" + case "copilot": + // GitHub's Copilot mark is the one already bundled for the GitHub surfaces. + return "ProviderGitHub" default: return nil } @@ -382,6 +403,14 @@ func workRailLogoProvider(for catalogGroupKey: String) -> String { return "ollama" case "lmstudio": return "lmstudio" + case "qwen": + return "qwen" + case "kimi", "moonshot": + return "kimi" + case "grok", "xai": + return "grok" + case "copilot", "github-copilot": + return "copilot" default: return catalogGroupKey } @@ -434,6 +463,15 @@ func workModelRowLogoProvider(for model: WorkModelOption, catalogGroupKey: Strin return "droid" } + // Copilot resells other vendors' models, so its rows show the upstream mark + // for the same reason Cursor and Droid rows do. + if group == "copilot" || modelId.hasPrefix("github-copilot/") { + if let brand = workUpstreamBrand(modelId: modelId) { + return brand + } + return "copilot" + } + if group == "opencode" || modelId.hasPrefix("opencode/") { if modelId.hasPrefix("opencode/") { let parts = modelId.split(separator: "/", omittingEmptySubsequences: true) @@ -515,6 +553,14 @@ func providerTint(_ provider: String?) -> Color { return .yellow case "factory": return .gray + case "qwen": + return .purple + case "kimi": + return .primary + case "grok": + return .red + case "copilot": + return .cyan default: return ADEColor.accent } @@ -545,6 +591,13 @@ func providerFamilyKey(_ provider: String) -> String { if raw == "droid" || raw == "factory" || raw.hasPrefix("droid") { return "droid" } + // ACP providers. Each folds its brand aliases onto the ADE provider id, so a + // session labelled "moonshot" and one labelled "kimi-chat" resolve to one + // family and cannot draw different marks for the same runtime. + if raw.hasPrefix("qwen") { return "qwen" } + if raw == "moonshot" || raw == "moonshotai" || raw.hasPrefix("kimi") { return "kimi" } + if raw == "xai" || raw.hasPrefix("grok") { return "grok" } + if raw == "github-copilot" || raw == "githubcopilot" || raw.hasPrefix("copilot") { return "copilot" } return raw } @@ -552,7 +605,7 @@ func providerFamilyKey(_ provider: String) -> String { /// Routed Pi models must stay on Pi rather than falling through to Claude. func workNormalizedChatProvider(_ provider: String) -> String { let family = providerFamilyKey(provider) - return ["claude", "codex", "cursor", "opencode", "droid", "pi"].contains(family) + return ["claude", "codex", "cursor", "opencode", "droid", "pi", "qwen", "kimi", "grok", "copilot"].contains(family) ? family : "claude" } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index eae43fb4e6..3a34f28aac 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -20483,6 +20483,12 @@ final class ADETests: XCTestCase { ) } + func testWorkModelCatalogUsesRequestedProviderOrder() { + let groups = workModelCatalogGroups(currentModelId: "", currentProvider: "codex") + + XCTAssertEqual(groups.map(\.key), ["claude", "codex", "cursor", "opencode", "droid"]) + } + func testWorkModelCatalogIncludesFlagshipModelMetadata() { let groups = workModelCatalogGroups(currentModelId: "", currentProvider: "codex") let claudeGroup = groups.first(where: { $0.key == "claude" }) diff --git a/docs/features/chat/acp-providers-spec.md b/docs/features/chat/acp-providers-spec.md new file mode 100644 index 0000000000..d3aa61e2ca --- /dev/null +++ b/docs/features/chat/acp-providers-spec.md @@ -0,0 +1,436 @@ +# ACP Provider Expansion — Build Spec (locked 2026-08-30) + +Locked by /plan deliberation in ADE session `1504018b-e2c5-4fd4-a954-f86c9f9c67e7`. +Four new Work providers over one shared ACP host: **qwen**, **kimi**, **grok**, **copilot**. +Full Settings → Models redesign for all providers. Research record lives in that chat. + +## 1. Architecture + +One shared ACP host module + four thin dialects. The host owns: process spawn + +process-tree kill, NDJSON JSON-RPC over stdio, `initialize`, session lifecycle +(`session/new|load|resume|prompt|cancel|close`), permission round-trips +(`session/request_permission` → ADE `approval_request`/`PendingInputRequest`), +and the event mapper `session/update` → `AgentChatEvent`. Dialects own: spawn +argv, env, auth probe, capability quirks, cancel/close/usage behavior, and the +slash-command allowlist. + +- New host code lives in `apps/desktop/src/main/services/chat/acpHost/`. +- Dependency: `@agentclientprotocol/sdk` (protocol v1; do NOT target v2 draft). +- Do NOT restore `droidAcpPool.ts` / `acpEventMapper.ts` patterns from git + history; this is a fresh design informed by their failure modes. +- Live IPC publishes uncompacted `liveEnvelope` — the mapper must not slim live + events (see `commitChatEvent` in `agentChatService.ts`). +- Stable row identity: `messageId` (text/thought) and `toolCallId` (tool rows). + Synthesize stable ids when a chunk has none. +- `session/load` replay must be suppressed when ADE already has a transcript; + prefer `session/resume` when advertised. +- Cancel is tracked client-side: ADE records that it cancelled and treats the + turn as `interrupted` regardless of the provider's stopReason. +- MCP injection at `session/new` is capability-gated per dialect; never inject + the Codex-signed computer-use MCP into ACP providers. +- Connection/process pooling keyed `{provider, cwd, env}` with idle TTL and a + generation counter (pattern: Emdash `connection/source.ts`). + +## 2. Tier policy + +| Provider | Tier | Notes | +|---|---|---| +| qwen | first-class | cleanest surface | +| kimi | first-class | two holes, absorbed (below) | +| grok | preview (Settings-only label) | **blocker CLEARED 2026-08-31, tier decision pending.** A real `session/request_permission` was observed in a host-driven ACP session on 1.0.13 once both halves of §3's neutralization were applied. Graduating to first-class is a product call, not a technical one; the remaining caveat is that the kill switch is an undocumented vendor hatch (§3 rule 3) | +| copilot | preview (Settings-only label) | graduates when GitHub fixes cancel + drops preview | + +Preview labels appear ONLY in Settings (tile + detail page). Pickers render all +providers identically. + +## 3. Per-provider dialects (verified facts — do not re-derive) + +### Qwen (`qwen --acp`, npm `@qwen-code/qwen-code` **0.22.3**) +- Caps: loadSession, session list/resume, image **and audio** prompts, MCP + http/sse. Slash via `available_commands_update`. **`session/close` is not + advertised and answers -32601.** ADE ends the process (one process per + session). Default `qwen --help` hides `--acp`, `--approval-mode`, + `--session-id`, `--yolo`, and `--append-system-prompt`; they exist (error-path + help lists them). +- Auth: `qwen auth` is **removed**. Advertised ACP method is `openai` + (`OPENAI_API_KEY`, optional `OPENAI_BASE_URL`, `--auth-type=openai`, or a + custom provider already saved in `~/.qwen/settings.json`). ADE does **not** + write that file — it reuses the Qwen CLI the user already configured, including + a local OpenAI-compatible proxy. Unauthenticated `session/new` is + `-32000 Authentication required: Use Qwen Code CLI to authenticate first.` + `authenticate` with `openai` and no key is `-32603 Internal error` whose + `data.details` say "Missing API key" even when the key already lives in + settings.json, so ADE's auth probe uses `session/new` as the proof. Free OAuth + tier is dead (2026-04). Live model ids come from `settings.json` + `modelProviders` plus anything a session later reports. +- Config home: `QWEN_HOME` names the config dir (CODEX_HOME shape). Runtime + state axis: `QWEN_RUNTIME_DIR`. Live probe: `QWEN_HOME` relocates + `installation_id`, extensions, `output-language.md`. +- Session config via `session/set_config_option` (mode/model/thinking). + Approval modes: plan|default|auto-edit|auto|yolo. +- Tracked CLI: `qwen -i "" -m --approval-mode= --session-id + `; resume `--resume ` / `--continue`; NEVER pass `--yolo` together + with `--approval-mode` (parse error: use `--approval-mode=yolo`). NEVER pass + `--session-id` with `--resume`/`--continue`. `--append-system-prompt` carries + ADE guidance. +- Windows: npm `.cmd` shim → prompt rides PTY (`promptRidesInArgv = platform + !== "win32"`), same rule as Claude. + +### Kimi (`kimi acp`, native binary **0.39.1**, repo MoonshotAI/kimi-code — NOT the +deprecated Python kimi-cli) +- Caps: loadSession, list, resume, **`session/close` (implemented; dummy id + returns `{}`)**, plus delete/fork/additionalDirectories. Image prompts yes, + audio no. MCP http/sse. `agentCapabilities.auth.logout` is advertised; ADE + has no ACP logout yet. **Usage on the wire still unverified** (hidden meter + + degradation note until an authenticated turn proves otherwise). +- Auth: `kimi login` / `kimi acp --login` device-code; region + `mainland-cn` (kimi.com) or `global` (kimi.ai). ADE does **not** write + `~/.kimi-code/config.toml`. `authenticate` method id + `login`, type `terminal`. Unauthenticated `session/new` is `-32000 + Authentication required`. +- Config home: `KIMI_CODE_HOME` (dir itself, default `~/.kimi-code`), + `config.toml`. Live probe: `kimi doctor` and ACP both honour it. Installer + default bin is `$HOME/.kimi-code/bin` — ADE's known-dir lookup includes that + path because `KIMI_NO_MODIFY_PATH` skips rc edits. Model flag takes an ALIAS, + not a raw model id. +- Tracked CLI: NO argv prompt for interactive TUI → use + `{ initialInput: prompt, initialInputDelayMs: 750 }` (Cursor-branch shape). + Non-interactive `-p/--prompt` exists and **cannot** combine with `--yolo` or + `--auto`. Resume `-S ` / `-c` (lowercase c). Permission: `--yolo` XOR + `--auto` (parse error: "Cannot combine --yolo with --auto"); `--plan`. Vendor + docs say permission flags ARE allowed on resume (Emdash's omit-on-resume is + stale) — verify with one live probe after login. +- Session id: NOT assignable at launch. Capture via sessions-dir disk-adopt + (pattern: `scheduleCodexSessionIdCaptureBestEffort` in `ptyService.ts`) or a + `SessionStart` hook. IDs are ULID-shaped. +- Windows: native binary; REQUIRES Git for Windows (bundled Git Bash is its + shell) → preflight check + clear error. + +### Grok (`grok agent stdio` — there is NO `grok acp`; npm `@xai-official/grok`, +Rust, Apache-2.0) +- Spawn: `_GROK_CLAUDE_MARKER_OVERRIDE=1 grok --no-auto-update --no-plan + --permission-mode agent --no-leader stdio` (flags between `agent` and + `stdio` are agent-scoped). `--permission-mode` is global and comes before + `agent`; it defaults to `default`. It is one HALF of the approval + neutralization — see the permission rules below; the environment variable is + the other half and neither works alone. Reasons for the other flags: + auto-update swaps the binary under the host; native plan mode hangs external + hosts; leader mode cross-contaminates sessions. ADE owns plan UX. The same + pair rides the tracked-CLI launch and resume commands. +- Caps: loadSession, list/resume/close all advertised and verified across host + restart. NO image/audio. MCP http/sse. +- Permissions ARE standard `session/request_permission`. Critical rules + (rewritten 2026-08-31 after a live 6-arm experiment on 1.0.13): + 1. Grok merges permission RULES from several sources and evaluates MODE + flags only AFTER those rules. No CLI flag, `startupHints` value, or ACP + `_meta` field can force ask-always on its own. `x.ai/yolo_mode_changed` + is method-not-found on 1.0.13. `_meta.autoMode:false` at `session/new` + does nothing. + 2. ROOT CAUSE of the silent auto-approval is the user's + `~/.claude/settings.json` `permissions.defaultMode: "auto"` — that value + seeds Grok's auto-classifier (`permission/manager/mod.rs:1487`, logs + "auto permission mode seeded from Claude defaultMode"). The 3 allow rules + `grok inspect` also reports from `settings.local.json` are near-harmless + by comparison. `GROK_HOME` does not scope the Claude read. Rules and mode + are steps 2 and 5 of the same pipeline, which is why removing the source + is the only lever. + 3. KILL SWITCH: `_GROK_CLAUDE_MARKER_OVERRIDE=1` in the child environment + (Grok source: `permission/claude_settings.rs::is_claude_import_marked`, + gating `resolve_permissions_with_provenance` + + `load_claude_env_with_project`). With it set, `grok inspect` reports + `Permissions └ Source: (none) └ 0 loaded`, and a cwd write raises a real + `session/request_permission`; rejecting it prevents the write. + **BOTH HALVES ARE MANDATORY**: `--permission-mode default` cancels the + user's own `~/.grok/config.toml [ui] permission_mode`, and the env var + cancels the Claude inheritance. Arm E proved dropping the mode flag + re-breaks approvals even with the env var set. + RISK: the underscore prefix marks a vendor-internal hatch. It is + undocumented and Grok ships ~daily, so ADE verifies the effect with a + cached, offline `grok inspect` preflight + (`main/services/ai/grokPermissionPreflight.ts`) and backs it with a + provider-agnostic runtime invariant + (`acpHost/acpSupervisionGuard.ts`): writes with zero + `session/request_permission` in an ask-style mode mark the session + unsupervised and emit one dismissible `system_notice`. + **THE RUNTIME INVARIANT IS THE LOAD-BEARING NET, NOT THE PREFLIGHT.** It + observes what the agent actually did. Every static pre-check attempted + here has been wrong three times running (single-source parse, + print-order dependence, and `defaultMode` invisibility — GATE GOTCHA A), + each time failing OPEN. Treat the preflight as an early warning and never + describe it, in code or in UI, as proof of supervision. + GATE GOTCHA A — **`grok inspect` CANNOT SEE `permissions.defaultMode`, + so DO NOT build the gate on it.** Its `Permissions` rows come from + per-rule provenance (`tag_with_source` over `config.rules`), so a + `settings.json` holding only `{"permissions":{"defaultMode":"auto"}}` + contributes zero rules and prints zero rows — byte-identical to a clean + machine — while still setting `prompt_policy: Auto`. Measured live + (fake HOMEs, /tmp): `defaultMode only → Source: (none), 0 loaded`; + `defaultMode + 1 rule → settings.json, 1 loaded`; `rule only → + settings.json, 1 loaded`. Same file, real ACP session, + `--permission-mode default`: no marker → 0 permission requests, write + COMPLETED; marker → 1 request, write prevented. An inspect-parsing gate + fails OPEN on exactly the documented root cause. (It also lists one + `Source:` row PER CONTRIBUTOR with a combined count, so a single-row + parse is additionally print-order dependent — a second, smaller trap.) + THE GATE ADE SHIPS instead is self-attestation: one throwaway + handshake-only agent spawn (`initialize` + `session/new`, never a prompt, + so zero spend and no user content in the log) using the session's exact + argv and env plus `--debug --debug-file`, then two tracing lines. + Verified on the defaultMode-only machine: no marker → `Claude compat + disabled` ×0, `auto permission mode seeded` ×1; marker → ×1, ×0. + `auto permission mode seeded from Claude defaultMode / prompt_policy` + reports actual manager state, so it sees what inspect cannot. `Claude + compat disabled (marker set in config.toml)` is positive proof the hatch + fired on THIS build — measured ×1 on every marker run even with no Claude + settings present — which makes this signal a LIVE REGRESSION DETECTOR: if + xAI renames or drops `_GROK_CLAUDE_MARKER_OVERRIDE`, the attestation + vanishes and ADE degrades loudly instead of silently losing supervision. + Renamed string, empty log, crash, and timeout all read as FAILED. Note + `inspect` does not honor `--debug-file` (no logger init), so this signal + exists only on the `agent stdio` path — the path sessions actually use. + Debug log is written to OS temp, size-capped, and deleted on every exit + path; `--debug` is deliberately NOT put on the user's real session, whose + logs would carry prompts and file contents. + PROBE RESIDUE (low, accepted) — `session/new` materializes a real session + directory that `session/close` does NOT remove, so each probe leaves + ~13.7 KB in `$GROK_HOME/sessions///`. Accepted + rather than fixed: containing it would mean pointing the probe at a + private `GROK_HOME`, which would stop it exercising the user's real + `~/.grok/config.toml` — including `[ui] permission_mode`, one of the two + halves under test — and fidelity to the session's real environment is the + probe's entire value. ADE also must not delete from `~/.grok`. Bounded by + the cache to roughly once per lane per Grok version. It is invisible in + Grok's own UI (`grok sessions list` reports "No sessions found"). + **If Grok session disk-adopt or session import is ever built, that code + MUST skip probe sessions**, or phantom entries will surface in ADE that + the user never created. They sit in the same `sessions//` + directory as real ones and are NOT filtered by whatever makes `sessions + list` skip them. Discriminator, measured: `events.jsonl` is exactly 0 + bytes and `chat_history.jsonl` contains only the system entry — no user + turns. + GATE GOTCHA B — silence has TWO causes. Step 3 of Grok's pipeline is + per-project remembered approvals (`CachedStateStore` / + `remember_tool_approvals`), evaluated BEFORE prompt policy, so a user who + once chose "always allow" — possibly in Grok's own TUI, outside ADE — + legitimately gets edits with zero RPCs. ADE cannot tell the two apart, so + the notice reports the OBSERVATION ("changed files here without asking + ADE to approve") and never attributes the decision; the detail body names + both causes. A banner that misfires is a banner users learn to ignore. + FALLBACK if the hatch disappears: point `GROK_HOME` at an ADE-owned dir + whose `config.toml` sets `[claude_compat] imported = true`. Documented + and verified working, but NOT the default: it strips Claude-derived + skills 50→47, agents 11→3, and MCP 4→2, and it moves `auth.json` and + the sessions dir. COPY `auth.json`, never symlink — Grok's token refresh + is rename-based and a symlink silently forks the credential. The env var + is surgical by comparison (skills/agents/MCP/`Claude.md` unaffected) and + writes nothing to the user's machine. + 4. Stamp `_meta.clientIdentifier: "ade"` at `initialize`. + 5. `x.ai/session_notification` `pending_interaction{kind:"permission"}` is a + spinner hint, NOT a permission request. Never answer it. + 6. Read/Grep/WebSearch never prompt (SAFE_COMMAND) — absence of prompts for + reads is normal. + 7. Real option ids offered are `allow-edits-session`, `allow-once`, + `reject-once` — NOT `enable-always-approve`. The bridge derives a kind + from the id, so an unrecognized id still lands on a safe kind. +- Cancel: send `session/cancel` as a JSON-RPC NOTIFICATION (request → -32601). + Result arrives as `stopReason:"cancelled"`. +- Usage: no standard `usage_update`; read usage+cost from the `session/prompt` + RESULT `_meta`. On 1.0.13 `costUsdTicks` and `modelUsage` sit under + `_meta.usage`, with token totals also at the top level. ADE accepts both + shapes (`costUsdTicks`, `modelUsage`, `cachedReadTokens`, plus nested + `usage`). `costUsdTicks` are nano-dollars (1_000_000_000 = $1.00). A + captured 30k-token ping at 86_649_000 ticks is $0.0866, not $86.65. +- `session/set_config_option` is non-standard (`configId`, undocumented value + enum) → set model/effort via spawn flags (`-m`, `--reasoning-effort`). +- NEVER advertise client `fs` capability (Grok proxies binary reads through + text fs and corrupts assets). `terminal` capability optional. +- Slash: `available_commands_update`, re-emitted repeatedly → dedupe. +- Config home: `GROK_HOME` IS a valid env override (`xai-dirs` reads it; + earlier "no override" text was wrong). ADE still sets nothing and reuses the + user's `~/.grok`, because a private home would hide their `grok login` + credential and rules. That is a choice, not a limitation. +- Tracked CLI: positional prompt `grok "

"`, `-s ` assign, `-r ` / + `-c` resume, `--permission-mode {default,acceptEdits,auto,dontAsk, + bypassPermissions,plan}`, `--reasoning-effort`, `--rules` (append guidance), + `--no-alt-screen`. NEVER pass `-w/--worktree` (collides with lanes). +- Auth: reuse `grok login` (`~/.grok/auth.json`) or `XAI_API_KEY`; stored + session token outranks env key. No free tier. +- Version churn ~daily; record binary version in diagnostics; floor ≥1.0.13. + +### Copilot (`copilot --acp`, npm `@github/copilot`, PREVIEW) +- Caps on 1.0.82 (ACP agent 1.0.4): `loadSession`, image prompts, session + list. `session/resume` and `session/close` are **not** advertised and + answer -32601. Slash as ordinary prompts + `available_commands_update`; + TUI-only commands (`/diff`, `/resume`, `/login`, `/undo`…) must be filtered + from the picker or they hit the model. +- KNOWN BUG: `session/cancel` as a REQUEST answers -32601. Send it as a + notification. Live 1.0.82 cancel mid-count returned `stopReason:"end_turn"` + with partial text `"1\n2\n3\n4\n5"` (github/copilot-cli #4561) → client-side + cancel accounting is mandatory. ADE still attempts `session/close` and + degrades, keeping the process for pooling. Real `session/prompt` turns work + (`"ping"`, usage on the prompt result + `usage_update`). `copilot -p --model + gpt-5.4` errors "not available"; the same flag on `--acp` is ignored and the + default model still answers. Config options use `currentValue` and nested + `value`, which ADE canonicalizes onto `value` / `options[].id`. +- Server-start flags (`--effort`, `--available-tools`, `--excluded-tools`) are + process-global; `session/new` cannot override. +- **Trust pre-seed: REMOVED. ADE does not write Copilot's config.** There was + once an `ensureCopilotFolderIsTrusted` helper that added the lane worktree to + `$COPILOT_HOME/config.json` before `session/new`. The helper, its call site in + `agentChatService.ts`, and its tests are deleted, and nothing on the Copilot + path may write the provider's config home again. + - **It bought nothing.** A three-arm live experiment on 1.0.82 opened + headless `session/new` with no trust key and no `--add-dir`, in a throwaway + git cwd and in a nested independent git repo. No arm deadlocked on a "do you + trust this folder" gate. Cwd writes completed in every arm with + `allow_all: "off"`, no `permissions-config.json`, a `tool_call` of kind + `edit`, and **0** `session/request_permission` RPCs. The write did not + enable permission prompts — Copilot ACP cannot be interactively gated + headless on this version, seed or no seed. + - **It cost something real.** `config.json` is JSONC (leading `//` comment + header). `JSON.parse` throws on that header, and the recover path rewrote a + user's live `~/.copilot/config.json` as a stub, dropping the comment header + and sibling keys; every later `session/prompt` answered "No model + available" until the file was restored. A no-overwrite guard was added + afterwards, but the correct fix is to not write user state at all. + - **Key name, for the record** (moot now that ADE writes neither, recorded so + nobody re-adds the wrong one): live 1.0.82 persists `trustedFolders` + (camelCase). Earlier research notes and older GitHub docs claimed + `trusted_folders` (snake_case); that spelling is wrong. Which key the binary + **reads** was never isolated, because ACP `session/new` opened with neither. + - `--add-dir` **stays** on the spawn plan. It is argv, not a rewrite of user + state, and it does not touch `config.json`. The experiment showed it is not + load-bearing for opening a session or for writes either, but it is the + cheapest available session path gate, so removing it needs its own decision. +- Auth: `copilot login` (browser local / device remote); free plan includes + the CLI. `authenticate` succeeds only after login. +- Config home: `COPILOT_HOME` + `--config-dir` flag. Sessions at + `~/.copilot/session-state//`. +- Tracked CLI: `copilot -i "" --model --reasoning-effort + `; `--resume=` doubles as assign-at-launch; + `--continue`. `--model` is a FIXED enum — map or reject. No plan mode → map + ADE plan to `--deny-tool write,shell` or reject the mode. `--no-alt-screen`. +- Windows: npm `.cmd` shim → prompt rides PTY. + +## 4. ADE contract extensions (all move together, one PR-layer) + +From the internal audit (all file:line refs verified 2026-08-30): +- `shared/types/chat.ts`: `AgentChatProvider` + `"qwen"|"kimi"|"grok"|"copilot"`, + `AgentChatModelCatalogRefreshProvider`, `PendingInputSource` + `"acp"`, + session fields → ONE generic `acpConfigSnapshot`/`acpPermissionMode` shape + (mirroring `cursorModeSnapshot`/`cursorConfigValues`), NOT four bespoke + field families. `ACTIVE_TURN_DISPATCH_MODES`: all four queue-only. + `HANDOFF_FORK_PROVIDERS`: exclude all four (brief-only handoff). +- `shared/modelRegistry.ts`: `ProviderFamily` + `"qwen"|"moonshot"|"xai"| + "github"` (or equivalent), `ModelProviderGroup` + four, curated descriptors + per provider (small set: the models users actually pick), helpers. +- `shared/modelCatalog.ts`: `ProviderGroupKey`, `PROVIDER_ORDER`, + `PROVIDER_GROUP_ORDER/COLORS`, `classifyProviderGroup` — replace silent + `default → "opencode"` with exhaustive `Record` tables. +- `shared/types/config.ts`: `AiProviderConnections` + four keys; + `AiSettingsStatus.availableProviders/models` records extended. +- `main/services/ai/authDetector.ts` (+ `CliName`), `providerConnectionStatus`, + `providerRuntimeHealth`: arms for four providers (binary detect + protocol + auth probe; Jean pattern: spawn, `initialize`+`authenticate`, map JSON-RPC + error to "Run ` login` first"). +- `main/services/shared/providerConfigHomes.ts`: `qwenConfigHome` (QWEN_HOME), + `copilotConfigHome` (COPILOT_HOME), `kimiCodeConfigHome` (KIMI_CODE_HOME) — + CODEX_HOME shape. Grok: none (use `~/.grok`). +- `shared/cliLaunch.ts`: `CliProvider` + four; launch/resume builders. Template: + claude branch for qwen/grok/copilot, cursor branch (initialInput) for kimi. +- `renderer/lib/sessions.ts`: `KnownChatProvider` + four; both maps + tool types. +- `agentChatService.ts`: `catalogProviders` + `loadAvailableModels` arms + (cached-or-fallback fast tier — NEVER probe synchronously on catalog read); + cross-machine preflight `activateRuntime` list + four (agentChatService.ts + ~:33921); fork capability → generic brief fallback. +- Catalog sync: connected-only filter + size cap (4.85 MB incident guard). +- Picker greying: `useProviderAuthStatus.familiesFromStatus` + four arms; + `providerEmptyState.PROVIDER_COPY` + four; `runtimeCatalogCache` + `REFRESH_PROVIDERS` + four and `refreshProviderForFamily` (qwen / moonshot→kimi + / xai→grok / github-copilot→copilot); `pickerFamilyForCatalogGroup` exhaustive. + Desktop `ALL_PROVIDER_FAMILIES` includes the four ACP families so the Work + picker rail always has Qwen / Kimi / Grok / GitHub Copilot tabs (Favorites + is a starred subset and will not list them until starred). +- TUI: `AdeCodeProvider`, `TUI_PROVIDER_OPTIONS`, `PROVIDER_FAMILY_LABELS`, + `PROVIDER_ORDER`, `modelPickerProviderAuthStatus`, `providerFromCatalogGroup` + (exhaustive, no codex fallback), icons (qwen + copilot marks needed; grok + + kimi exist), peripheral lists (`adeRpcServer` enum, `remoteLauncher`, + `agentRegistry`, login commands). +- iOS: `workModelGroupOrder` (+4 or the phone silently drops the groups), + label/icon/tint/asset/family switch tables, `ProviderGitHub` asset reusable + for copilot; qwen/kimi/grok assets needed. Ship in the same release train. +- Preload/IPC: extend generic `ade.ai.*` surfaces; keep preload/shared/renderer + types in sync (runtime-backed null services rule). + +## 5. Settings → Models redesign (all 9-10 providers) + +- Routing: single settings route stays; sub-view via `?tab=agents&provider= + `. ~10 new `SETTINGS_ENTRIES` (one per provider) so ⌘K + deeplinks work; + `#ai-providers` and legacy aliases keep resolving (settingsManifest.test.ts + invariants). +- Grid: responsive `repeat(auto-fit, minmax(280px, 1fr))`. Reuse/extend + `providerSectionPrimitives.tsx` (`ProviderGrid`/`ProviderTile`). +- Tile (labeled): logo · name · status dot + word (Connected / Sign in / + Needs attention / Not installed / Checking / Disabled) · model count · + version · Preview chip (grok/copilot) · one-line error when unhealthy. + "Checking" is a first-class state distinct from "Not detected". +- Detail page: two-column. Left rail: identity, status, version (pinned — NO + update-available surface), auth actions (sign in/out), diagnostics entry, + disable toggle. Right: models (curated ★default + discovered, search), + permission defaults, default model, usage bars where the provider reports + them (hidden for kimi). +- Architecture: descriptor-driven. One `ProviderCard`/`ProviderDetailPage` + parameterized by a per-provider descriptor + an auth-body slot for the + genuinely bespoke flows (Pi catalog, OpenCode catalog, Cursor OAuth). Do not + copy-paste per-provider JSX (the disease being cured). +- Permission defaults move here; keep the composer tables as the write path is + one-way abstract→native — the detail page writes the ABSTRACT mode only. +- The model-list call IS the health check: a failed enumerate renders as an + error row under that provider (VS Code pattern); no Verify button. + +## 6. Extras (locked) + +1. Embedded terminal sign-in modal: real PTY running the provider login + command; auto-open OAuth URL; auto-close when auth probe flips green; + reachable from Settings AND the chat auth_required error card. +2. Settings search aliases: brand keywords (qwen, moonshot, kimi, copilot, + github, grok, xai, acp…) route settings search/⌘K to provider pages. +3. Vendor doctor in diagnostics: run `grok doctor` / `kimi doctor` where + available; fold output into the copyable diagnostic report. +4. Honest-degradation first-use notes: one dismissible line per known hole + (e.g. "Kimi doesn't report token usage — usage meter hidden for this chat"). + +Rejected: env-var provenance surfacing, authenticating pulse animation, +update-available UI, picker overhaul beyond greying. + +## 7. Test contract + +- `run | degrade` conformance matrix (AgentConnect pattern): every + (feature × provider) cell asserts either works, or gracefully absent — + never throws, never hangs. Features: capabilities, lifecycle, prompt/stream, + permission round-trip, cancel, close/eviction, resume, slash advertise, + usage fold, MCP injection. +- Scripted mock ACP agent + recorded fixture replay for CI (no credentials). +- Capability declarations use a `requiresBehavior`-style invariant: a dialect + that declares a capability must supply the behavior (compile-time where + possible). +- Exhaustive `Record` tables + `AssertNever` replace switch-defaults. +- Windows parity is default-required: hidden console, process-tree kill + (`taskkill /T /F`), `.cmd` shim prompt rule, Kimi Git-Bash preflight. + +## 8. Work-unit ownership (build order) + +- **W1-contracts**: §4 sweep (types, registry, plumbing, cliLaunch, cross- + machine, picker greying, TUI lists). Owner boundary: everything in §4. +- **W2-host**: `acpHost/` module + mock-agent test harness. New files only; + integration seam documented, not wired. +- **W3-settings-ui**: §5 redesign against existing six providers with the + descriptor architecture; four ACP descriptors plug in later. +- **W4-wire**: agentChatService integration (runtime adapter per provider using + W2 host + W1 contracts), auth probes, catalog arms live. +- **W5-dialect-verify**: live smoke per provider + preview graduation probes + (grok permission prompt, kimi resume-permission-flag). +- **W6-extras**: §6 items. +- **W7-parity**: TUI + iOS surfaces, sync allowlists. +- **W8-tests**: §7 harness + regression suites; then /quality → /test → /ship + as stacked PRs. diff --git a/docs/features/chat/acp-verification-brief.md b/docs/features/chat/acp-verification-brief.md new file mode 100644 index 0000000000..4fcc463e21 --- /dev/null +++ b/docs/features/chat/acp-verification-brief.md @@ -0,0 +1,139 @@ +# ACP Provider Verification Brief + +You own the verification of the ACP provider work in this lane. Work +autonomously. Do not wait for the human. Report only when you finish, or when a +decision is genuinely theirs. + +## What exists + +Read `docs/features/chat/acp-providers-spec.md` first. It is the locked build +spec. Everything below assumes it. + +Four new Work providers (`qwen`, `kimi`, `grok`, `copilot`) run over one shared +ACP host. Eight work units build the feature and its verification coverage. + +| Area | Path | +|---|---| +| Shared ACP host | `apps/desktop/src/main/services/chat/acpHost/` | +| Dialects | `acpHost/acpDialects/{qwen,kimi,grok,copilot}.ts` | +| Mock agent + matrix | `acpHost/mockAcpAgent.ts`, `acpHost/acpHost.test.ts` | +| Chat runtime adapter | `main/services/chat/agentChatService.ts` (`AcpRuntime`, `ensureAcpSessionRuntime`, `runAcpTurn`) | +| Auth probe | `main/services/ai/acpAuthProbe.ts` | +| Executables | `main/services/ai/acpExecutables.ts` | +| Diagnostics | `main/services/ai/acpProviderDiagnostics.ts` | +| Tracked CLI launch | `shared/cliLaunch.ts` | +| Settings UI | `renderer/components/settings/providers/` | +| TUI parity | `apps/ade-cli/src/tuiClient/` | +| iOS parity | `apps/ios/ADE/` | + +## The problem you are solving + +The human holds no subscription for Qwen, Kimi, or Grok, and does not plan to +test them by hand. **Copilot may be logged in on this machine — check, and if it +is, exercise it for real.** Everything else must be proven under the hood. + +Your job: prove each provider works, or name exactly what is broken. Do not +report "tests pass" as proof that a provider works. The existing suites use a +mock agent that ADE itself wrote; a mock cannot falsify a wrong assumption about +a real CLI. + +## What to do + +### 1. Establish real ground truth per provider + +For each of the four, find out what is actually installed and authenticated on +this machine (`acpExecutables.ts` shows where ADE looks). Then, for every +provider whose binary exists: + +- Drive the real binary yourself over stdio: `initialize`, `session/new`, + `session/prompt`, permission round-trip, `session/cancel`, `session/close`. +- Compare the real handshake against the dialect declaration. Every + `agentCapabilities` claim ADE makes must match what the binary advertises. +- Where ADE declares a capability the binary does not have, that is a defect. + Where the binary has one ADE ignores, that is a finding. + +Record the real `initialize` response for each reachable provider as a fixture. +Fixtures captured from real binaries are worth more than any mock. + +Copilot is the priority: if it is authenticated, run a full chat turn through +ADE's own runtime, not just raw stdio. Verify the cancel bug handling +(`stopReason: "end_turn"` after cancel must still read as interrupted). + +### 2. Attack the assumptions the mock cannot test + +The spec encodes verified vendor facts. Several are load-bearing and were +verified once, on one version. Re-verify what you can and flag what you cannot: + +- Grok: the auto-mode neutralization (`x.ai/yolo_mode_changed` after + `session/new`) and that permissions actually prompt. The user's + `~/.claude/settings.json` `defaultMode` leaks into Grok; confirm ADE defeats + it. This is the single most important Grok check. +- Grok: cancel must be a notification, not a request. +- Kimi 0.39.1: `session/close` is advertised and implemented. Usage on the wire + is still unverified. Interactive TUI still has no argv prompt. +- Qwen 0.22.3: `--session-id` vs `--resume`/`--continue` and `--yolo` vs + `--approval-mode` are parse errors. `session/close` is **not** implemented. +- Copilot: `config.json` is JSONC; live 1.0.82 persists `trustedFolders` + (camelCase — not the `trusted_folders` older notes claimed). ADE writes + neither: the trust pre-seed is removed and nothing on the Copilot path may + write `$COPILOT_HOME` again. + Headless ACP `session/new` did not deadlock without a seed or `--add-dir`. + Cwd writes emit 0 `session/request_permission` with `allow_all` off. + +### 3. Hunt the classes of bug a mock hides + +Read the ADE bug classes in `.claude/skills/quality/references/` if present, and +`docs/features/chat/README.md` fragile-wiring section. Then go looking for: + +- Stream ordering and the text-flush invariant under real chunk timing. +- Turn lifecycle: every path must reach a terminal `done` so the composer + releases. Try setup failure, mid-turn kill, permission left open at teardown, + process exit during a prompt. +- Pool identity: two chats, same lane, different models must not share a + process. Two chats, same everything, should. +- Resume after a simulated ADE restart: session id persists, replay is + suppressed when ADE already has a transcript, no duplicate rows. +- Windows-only code paths: read them and reason about correctness even though + you cannot run them. Process-tree kill, `.cmd` shim prompt delivery, the Kimi + Git-Bash preflight. +- Permission cancellation: an outstanding permission RPC must be cancelled when + the turn stops, and must not leave a card stuck in the transcript. + +### 4. Widen the automated net where it is thin + +Where you find a gap a test could have caught, add the test. Prefer tests that +would fail today if the code were wrong, over tests that restate the +implementation. Extend the run/degrade conformance matrix rather than inventing +a parallel harness. Use recorded real-binary fixtures where you captured them. + +Do not add brittle render tests. Do not snapshot-test UI pixels. + +### 5. Fix what you find + +Fix the defects you can fix safely, in this worktree. Keep each fix narrow and +add the regression test with it. If a fix needs a product decision, or changes +behavior this lane was not asked to change, write it up instead of doing it. + +## Rules + +- Stay in this lane worktree for all edits. Read-only outside is fine. +- **Do not start the ADE desktop app.** The human drives it and it is + intentionally down. Everything here is doable headless. +- Do not commit and do not open a PR. +- Never write to the main checkout. Never run `git stash` outside this worktree. +- Do not install tools or packages without asking. +- Do not spend real money. Cheap probes only. Say so if a check needs a paid + subscription the machine lacks. +- Run typechecks and scoped tests, sharded. Do not run the whole suite serially. + +## What to report + +A single structured report: + +1. Per provider: reachable / authenticated / not installed, and what you proved + about each — with the evidence, not the intention. +2. Defects found, ranked, each with file:line, a repro, and whether you fixed it. +3. Assumptions in the spec you could NOT verify, and exactly what would verify + them. Be honest here; unverified is not the same as working. +4. Tests added, and what each would catch. +5. Anything you believe the human must decide. diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 9a60a81b82..9a9ae587a6 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -835,8 +835,8 @@ power the TUI picker (`apps/ade-cli/src/tuiClient/components/ModelPicker/`). | Module | Role | |---|---| | `ModelPicker.tsx` | Trigger + popover entry point. Owns runtime-catalog loading via `runtimeCatalogCache`, fast mode, and the favorites/recents fan-out. Pass `fastMode` + `onFastModeChange` and the picker owns the affordance: a per-row Fast chip inside the popover plus a ` Fast` trigger suffix composed by the pure `composeModelPickerTriggerLabel` helper. Surfaces that pass neither render no fast affordance at all; the deprecated `fastModeActive` / `onFastModeToggle` / `fastModeSupported` props still render the old sibling chip for call sites that have not migrated. | -| `ModelPickerContent.tsx` | The popover body: search bar, rail, virtualized list (`@tanstack/react-virtual`), empty state. Props include `hidePermissionRail` (forward-compat hook for orchestrated surfaces that suppress permission-related affordances), `allowCliOnlyModels` (switch Cursor filtering from SDK chat models to CLI launch models), `allowRegistryExpansion` (when false, skip merging `MODEL_REGISTRY` entries into the runtime catalog), `registryFilter` (restrict registry expansion by descriptor, used by fork handoffs to keep the provider fixed without freezing the picker to a stale concrete-id list), and `runtimePin` (the prompt-box / chat machine, forwarded into auth and OpenCode-installed probes). When the authenticated-only filter is active, authenticated CLI-backed providers (Claude, Codex, Droid) may expand from the static registry even if the last discovered model-id list is incomplete. Estimated row height `MODEL_ROW_ESTIMATED_HEIGHT = 44`. | -| `ModelPickerRail.tsx` | Left-rail tabs (Favorites / Recents / per-provider groups). Reads `AuthStatus` per family to render auth gates and the OpenCode "Install OpenCode" CTA from `providerEmptyState`. | +| `ModelPickerContent.tsx` | The popover body: search bar, rail, virtualized list (`@tanstack/react-virtual`), empty state. Props include `hidePermissionRail` (forward-compat hook for orchestrated surfaces that suppress permission-related affordances), `allowCliOnlyModels` (switch Cursor filtering from SDK chat models to CLI launch models), `allowRegistryExpansion` (when false, skip merging `MODEL_REGISTRY` entries into the runtime catalog), `registryFilter` (restrict registry expansion by descriptor, used by fork handoffs to keep the provider fixed without freezing the picker to a stale concrete-id list), and `runtimePin` (the prompt-box / chat machine, forwarded into auth and OpenCode-installed probes). When the authenticated-only filter is active, authenticated CLI-backed providers (Claude, Codex, Droid, Qwen, Kimi, Grok, Copilot) may expand from the static registry even if the last discovered model-id list is incomplete. The left rail always includes those ACP families (plus Cursor / OpenCode / local runtimes) so they stay reachable before catalog refresh; Favorites only lists starred models. Estimated row height `MODEL_ROW_ESTIMATED_HEIGHT = 44`. | +| `ModelPickerRail.tsx` | Left-rail tabs (Favorites, Recents, Anthropic, OpenAI, Cursor, OpenCode, Pi, GitHub Copilot, Grok, Droid, Kimi, Qwen, Ollama, LM Studio). The desktop, hosted renderer, TUI, and iOS catalog keep this provider order (Cursor is omitted on unsupported Windows ARM); reads `AuthStatus` per family to render auth gates and the OpenCode "Install OpenCode" CTA from `providerEmptyState`. | | `ModelListRow.tsx` | A single model row (favorite star, brand logo, display name, sub-provider chip, availability tone). Also renders the muted Fast chip when the surface supplied `onFastModeChange` and `modelSupportsFastMode()` holds for that row's descriptor; toggling it changes neither the selection nor the popover's open state. | | `ReasoningEffortPicker.tsx` | Standalone reasoning-effort dropdown, mounted next to the model trigger and inside per-slot parallel-launch controls. | | `modelCatalog.ts` | `descriptorsFromAgentChatModelCatalog`, `mergeSelectorModels`, `resolveModelDescriptorWithRuntimeCatalog`, `createUnknownModelPlaceholder` — pure helpers that flatten the IPC catalog into a `ModelDescriptor[]` and reconcile it with the static registry while preserving runtime metadata such as `serviceTiers` and Cursor `cursorAvailability`. All four take the same optional catalog scope key as `runtimeCatalogCache.ts`: descriptors are remembered per machine because a catalog's `reasoningEfforts` (the thinking-level ladder) and context window are machine-reported. There is deliberately **no fallback to another machine's bucket** — answering a miss from the bound machine is the same cross-machine leak the bucketing exists to prevent. A miss falls through to the static registry and then to `createUnknownModelPlaceholder`: correct-but-generic beats confident-and-wrong. | diff --git a/docs/features/onboarding-and-settings/configuration-schema.md b/docs/features/onboarding-and-settings/configuration-schema.md index 9245064401..2cfbf4bc4f 100644 --- a/docs/features/onboarding-and-settings/configuration-schema.md +++ b/docs/features/onboarding-and-settings/configuration-schema.md @@ -284,6 +284,7 @@ type AiConfig = { localProviders?: AiLocalProviderConfigs; customProviders?: AiCustomProviderConfig[]; // user-defined OpenAI-/Anthropic-compatible providers customModelSlugs?: string[]; // extra provider/model slugs pinned as selectable + disabledProviders?: string[]; // providers switched off in Settings workerSafety?: WorkerSafetyPolicy; featureModelOverrides?: Partial>; featureReasoningOverrides?: Partial>; @@ -308,6 +309,22 @@ terminal summaries try the setting, then the stored launch model, and skip the AI call when both are missing. Live chat compaction stays on the chat's own provider. +### Disabled providers + +`ai.disabledProviders` holds the ids of providers switched off with the +toggle on a provider's page in Settings → Agents & Models. A disabled +provider keeps its tile (reading **Disabled**) and its page, so the +switch is always findable, and it offers no models anywhere else: it is +dropped from `getAvailableModels`, from the model catalog the pickers, +the phone, and the relay all read, and from the AI status payload. + +Ids are lower-cased on read but never validated against the current +provider list — the field crosses the sync wire, and dropping an id a +newer build wrote would silently re-enable a provider on the other +machine. Like `customProviders`, the field uses replace semantics on +merge: the UI writes the whole authoritative list, so an empty array +clears it and an absent key keeps what is stored. + ### Custom providers and model slugs `ai.customProviders` and `ai.customModelSlugs` back the **Advanced —