diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 293074d72..2c84be187 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -8,7 +8,6 @@ "name": "ade-cli", "version": "0.0.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.20.0", "@anthropic-ai/claude-agent-sdk": "^0.2.139", "@cursor/sdk": "^1.0.9", "@linear/sdk": "^84.0.0", @@ -52,15 +51,6 @@ "node": ">=22.0.0" } }, - "node_modules/@agentclientprotocol/sdk": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.20.0.tgz", - "integrity": "sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ==", - "license": "Apache-2.0", - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", @@ -6631,12 +6621,6 @@ } }, "dependencies": { - "@agentclientprotocol/sdk": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.20.0.tgz", - "integrity": "sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ==", - "requires": {} - }, "@alcalzone/ansi-tokenize": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 36337426e..3b696b086 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -24,7 +24,6 @@ "test": "vitest run" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.20.0", "@anthropic-ai/claude-agent-sdk": "^0.2.139", "@cursor/sdk": "^1.0.9", "@linear/sdk": "^84.0.0", diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index c1133cf46..8dbf64af0 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -1383,7 +1383,7 @@ function loginUnavailableHint(provider: AdeCodeProvider): string { return "ADE Cursor chat uses @cursor/sdk, which requires a Cursor API key. Open Settings > AI Providers, use ADE's encrypted key store, or set CURSOR_API_KEY before launching ADE."; } if (provider === "droid") { - return "ADE Droid chat runs Factory Droid over ACP. Set FACTORY_API_KEY before launching ADE, or run `droid` and use its interactive `/login`."; + return "ADE Droid chat uses the Factory Droid SDK. Set FACTORY_API_KEY before launching ADE, or run `droid` and use its interactive `/login`."; } return "No terminal login command is known for this provider."; } diff --git a/apps/ade-cli/tsup.config.ts b/apps/ade-cli/tsup.config.ts index d30b4ed32..349de07da 100644 --- a/apps/ade-cli/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; const external = [ - "@agentclientprotocol/sdk", "@anthropic-ai/claude-agent-sdk", "@cursor/sdk", "@wize-logic/nodejs-rfb", diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 60c9f056d..7c929894c 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.0-beta.1", "license": "AGPL-3.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.20.0", "@anthropic-ai/claude-agent-sdk": "^0.2.139", "@cursor/sdk": "^1.0.13", "@factory/droid-sdk": "^0.2.0", @@ -114,15 +113,6 @@ "wait-on": "^7.2.0" } }, - "node_modules/@agentclientprotocol/sdk": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.20.0.tgz", - "integrity": "sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ==", - "license": "Apache-2.0", - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1b745f45a..ad3c08000 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,7 +49,6 @@ "version:release": "node ./scripts/set-release-version.mjs" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.20.0", "@anthropic-ai/claude-agent-sdk": "^0.2.139", "@cursor/sdk": "^1.0.13", "@factory/droid-sdk": "^0.2.0", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 237d58527..2eb8e6046 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -52,7 +52,6 @@ import { createJobEngine } from "./services/jobs/jobEngine"; import { createAiIntegrationService } from "./services/ai/aiIntegrationService"; import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "./services/ai/cliExecutableResolver"; import { createAgentChatService } from "./services/chat/agentChatService"; -import { shutdownAcpCliConnections } from "./services/chat/acpCliPool"; import { createGithubService } from "./services/github/githubService"; import { createProjectScaffoldService } from "./services/projects/projectScaffoldService"; import { createFeedbackReporterService } from "./services/feedback/feedbackReporterService"; @@ -5559,11 +5558,6 @@ app.whenReady().then(async () => { } shutdownOpenCodeServersBestEffort(); - try { - shutdownAcpCliConnections(); - } catch { - // ignore - } }; const finalizeAppExit = (exitCode: number): void => { diff --git a/apps/desktop/src/main/services/chat/acpCliPool.ts b/apps/desktop/src/main/services/chat/acpCliPool.ts deleted file mode 100644 index b7f34985f..000000000 --- a/apps/desktop/src/main/services/chat/acpCliPool.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { Readable, Writable } from "node:stream"; -import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type InitializeResponse, -} from "@agentclientprotocol/sdk"; -import type { AcpHostBridge, AcpHostTermState } from "./acpHostClient"; -import { createAcpHostClient } from "./acpHostClient"; -import { - destroyChildProcessStreams, - signalChildProcessTree, - terminateChildProcessTree, -} from "../shared/utils"; - -export type AcpCliSpawnSpec = { - command: string; - args: string[]; - cwd: string; - env?: NodeJS.ProcessEnv; -}; - -export type AcpCliPoolOptions = { - poolKey: string; - logPrefix: string; - spawn: AcpCliSpawnSpec; - appVersion: string; - afterInitialize?: (args: { - connection: ClientSideConnection; - initResult: InitializeResponse; - }) => Promise; -}; - -export type AcpCliPooled = { - connection: ClientSideConnection; - bridge: AcpHostBridge; - terminals: Map; - dispose: () => void; -}; - -const pools = new Map(); -/** In-flight initialization per pool key — concurrent acquires share one spawn + handshake. */ -const pendingInit = new Map>(); -const pendingInitProcesses = new Map(); -let poolEpoch = 0; - -const STDERR_LOG_MAX = 8_192; -const ACP_CLI_ACQUIRE_MAX_ATTEMPTS = 12; -const ACP_CLI_ACQUIRE_RETRY_BACKOFF_MS = 25; - -function killProcQuiet(proc: ChildProcessWithoutNullStreams | null): void { - if (!proc) return; - try { - signalChildProcessTree(proc, "SIGKILL"); - } catch { - // ignore - } - destroyChildProcessStreams(proc); -} - -function evictPoolEntry(poolKey: string, reason: string, err?: unknown): void { - const entry = pools.get(poolKey); - if (!entry) return; - console.error( - `${reason} for poolKey=${poolKey}:`, - err instanceof Error ? err.message : err ?? "", - ); - try { - entry.pooled.dispose(); - } catch { - // ignore - } - pools.delete(poolKey); -} - -export async function acquireAcpCliConnection(options: AcpCliPoolOptions): Promise { - const key = options.poolKey; - const initEpoch = poolEpoch; - - for (let attempt = 0; attempt < ACP_CLI_ACQUIRE_MAX_ATTEMPTS; attempt += 1) { - if (attempt > 0) { - await new Promise((r) => setTimeout(r, ACP_CLI_ACQUIRE_RETRY_BACKOFF_MS)); - } - - const existing = pools.get(key); - if (existing) { - existing.ref += 1; - return existing.pooled; - } - - let initOwner = false; - let init = pendingInit.get(key); - if (!init) { - initOwner = true; - init = (async () => { - let proc: ChildProcessWithoutNullStreams | null = null; - const stderrChunks: Buffer[] = []; - const appendStderr = (d: Buffer | string): void => { - const buf = Buffer.isBuffer(d) ? d : Buffer.from(String(d), "utf8"); - stderrChunks.push(buf); - let total = 0; - for (const c of stderrChunks) total += c.length; - while (total > STDERR_LOG_MAX && stderrChunks.length > 1) { - total -= stderrChunks.shift()!.length; - } - }; - - try { - if (initEpoch !== poolEpoch) { - throw new Error("acpCliPool shutdown before spawn"); - } - proc = spawn(options.spawn.command, options.spawn.args, { - stdio: ["pipe", "pipe", "pipe"], - env: options.spawn.env ?? { ...process.env }, - cwd: options.spawn.cwd, - detached: process.platform !== "win32", - }); - pendingInitProcesses.set(key, proc); - - let failureHandled = false; - const onProcFailure = (label: string, err?: unknown) => { - if (failureHandled) return; - failureHandled = true; - const tail = Buffer.concat(stderrChunks).toString("utf8").trim(); - if (tail) { - console.error(`${options.logPrefix} ${label} stderr (tail) poolKey=${key}:`, tail); - } - killProcQuiet(proc); - evictPoolEntry(key, `${options.logPrefix} ${label}`, err); - }; - - proc.once("error", (err) => { - onProcFailure("process error", err); - }); - proc.once("close", (code, signal) => { - if (!pools.has(key)) return; - onProcFailure(`process closed code=${code} signal=${signal}`); - }); - - proc.stderr?.on("data", appendStderr); - - const terminals = new Map(); - const bridge: AcpHostBridge = { - onPermission: null, - onSessionUpdate: null, - getRootPath: () => options.spawn.cwd || "", - getDirtyFileText: null, - onTerminalOutputDelta: null, - flushTerminalOutput: null, - onTerminalDisposed: null, - }; - - const client = createAcpHostClient(bridge, terminals, { logPrefix: options.logPrefix }); - const toAgentStdin = Writable.toWeb(proc.stdin as Writable); - const fromAgentStdout = Readable.toWeb(proc.stdout as Readable); - const stream = ndJsonStream( - toAgentStdin as unknown as WritableStream, - fromAgentStdout as unknown as ReadableStream, - ); - const connection = new ClientSideConnection(() => client, stream); - - const initResult = await connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientInfo: { name: "ade", title: "ADE", version: options.appVersion }, - clientCapabilities: { - fs: { readTextFile: true, writeTextFile: true }, - terminal: true, - }, - }); - - if (options.afterInitialize) { - await options.afterInitialize({ connection, initResult }); - } - - const pooled: AcpCliPooled = { - connection, - bridge, - terminals, - dispose: () => { - for (const termId of terminals.keys()) { - bridge.onTerminalDisposed?.(termId); - } - for (const t of terminals.values()) { - try { - if (!t.exited) signalChildProcessTree(t.proc, "SIGKILL"); - } catch { - // ignore - } - destroyChildProcessStreams(t.proc); - } - terminals.clear(); - try { - if (proc) { - terminateChildProcessTree(proc, null, 1_500); - } - } catch { - // ignore - } - }, - }; - - if (initEpoch !== poolEpoch) { - throw new Error("acpCliPool shutdown during initialization"); - } - pools.set(key, { ref: 1, pooled }); - } catch (err) { - const tail = Buffer.concat(stderrChunks).toString("utf8").trim(); - if (tail) { - console.error(`${options.logPrefix} init failed stderr (tail) poolKey=${key}:`, tail); - } - killProcQuiet(proc); - evictPoolEntry(key, `${options.logPrefix} initialization failed`, err); - throw err; - } finally { - pendingInitProcesses.delete(key); - } - })().finally(() => { - pendingInit.delete(key); - }); - pendingInit.set(key, init); - } - - try { - await init; - } catch (err) { - if (initOwner) throw err; - continue; - } - - const entry = pools.get(key); - if (!entry) { - continue; - } - if (!initOwner) { - entry.ref += 1; - } - return entry.pooled; - } - - throw new Error( - `acpCliPool: exceeded ${ACP_CLI_ACQUIRE_MAX_ATTEMPTS} acquire attempts for poolKey=${key} (init or pool entry never became ready).`, - ); -} - -export function releaseAcpCliConnection(poolKey: string): void { - const entry = pools.get(poolKey); - if (!entry) return; - entry.ref -= 1; - if (entry.ref <= 0) { - entry.pooled.dispose(); - pools.delete(poolKey); - } -} - -/** True when the inner ACP pool still holds a live connection for this key (not evicted after process exit). */ -export function hasActiveAcpCliPoolEntry(poolKey: string): boolean { - return pools.has(poolKey); -} - -export function shutdownAcpCliConnections(): void { - poolEpoch += 1; - for (const entry of pools.values()) { - try { - entry.pooled.dispose(); - } catch { - // ignore - } - } - pools.clear(); - for (const proc of pendingInitProcesses.values()) { - killProcQuiet(proc); - } - pendingInitProcesses.clear(); - pendingInit.clear(); -} diff --git a/apps/desktop/src/main/services/chat/acpConfigState.test.ts b/apps/desktop/src/main/services/chat/acpConfigState.test.ts deleted file mode 100644 index 06980a0a1..000000000 --- a/apps/desktop/src/main/services/chat/acpConfigState.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SessionConfigOption } from "@agentclientprotocol/sdk"; -import { readAcpConfigSnapshot } from "./acpConfigState"; - -describe("readAcpConfigSnapshot", () => { - it("extracts model and mode selectors when ACP categorizes them", () => { - const configOptions: SessionConfigOption[] = [ - { - id: "session-model", - name: "Model", - category: "model", - type: "select", - currentValue: "composer-2", - options: [ - { value: "auto", name: "Auto" }, - { value: "composer-2", name: "Composer 2" }, - ], - }, - { - id: "session-mode", - name: "Mode", - category: "mode", - type: "select", - currentValue: "ask", - options: [ - { value: "ask", name: "Ask" }, - { value: "plan", name: "Plan" }, - ], - }, - ]; - - expect(readAcpConfigSnapshot(configOptions)).toEqual({ - modeConfigId: "session-mode", - currentModeId: "ask", - availableModeIds: ["ask", "plan"], - modelConfigId: "session-model", - currentModelId: "composer-2", - availableModelIds: ["auto", "composer-2"], - configOptions: [ - { - id: "session-model", - name: "Model", - category: "model", - type: "select", - currentValue: "composer-2", - options: [ - { value: "auto", label: "Auto", description: null, groupId: null, groupLabel: null }, - { value: "composer-2", label: "Composer 2", description: null, groupId: null, groupLabel: null }, - ], - }, - { - id: "session-mode", - name: "Mode", - category: "mode", - type: "select", - currentValue: "ask", - options: [ - { value: "ask", label: "Ask", description: null, groupId: null, groupLabel: null }, - { value: "plan", label: "Plan", description: null, groupId: null, groupLabel: null }, - ], - }, - ], - }); - }); - - it("falls back to option names and handles grouped model options", () => { - const configOptions: SessionConfigOption[] = [ - { - id: "model-selector", - name: "Session model", - type: "select", - currentValue: "gpt-5.4", - options: [ - { - group: "openai", - name: "OpenAI", - options: [ - { value: "gpt-5.4", name: "GPT-5.4" }, - ], - }, - { - group: "anthropic", - name: "Anthropic", - options: [ - { value: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, - ], - }, - ], - }, - ]; - - expect(readAcpConfigSnapshot(configOptions)).toEqual({ - modeConfigId: null, - currentModeId: null, - availableModeIds: [], - modelConfigId: "model-selector", - currentModelId: "gpt-5.4", - availableModelIds: ["gpt-5.4", "claude-sonnet-4.6"], - configOptions: [ - { - id: "model-selector", - name: "Session model", - type: "select", - currentValue: "gpt-5.4", - options: [ - { value: "gpt-5.4", label: "GPT-5.4", description: null, groupId: "openai", groupLabel: "OpenAI" }, - { value: "claude-sonnet-4.6", label: "Claude Sonnet 4.6", description: null, groupId: "anthropic", groupLabel: "Anthropic" }, - ], - }, - ], - }); - }); - - it("ignores invalid current values that do not match any selectable option", () => { - const configOptions: SessionConfigOption[] = [ - { - id: "session-model", - name: "Model", - category: "model", - type: "select", - currentValue: "default[]", - options: [ - { value: "auto", name: "Auto" }, - { value: "composer-2", name: "Composer 2" }, - ], - }, - { - id: "session-mode", - name: "Mode", - category: "mode", - type: "select", - currentValue: "default[]", - options: [ - { value: "edit", name: "Edit" }, - { value: "plan", name: "Plan" }, - ], - }, - ]; - - expect(readAcpConfigSnapshot(configOptions)).toEqual({ - modeConfigId: "session-mode", - currentModeId: null, - availableModeIds: ["edit", "plan"], - modelConfigId: "session-model", - currentModelId: null, - availableModelIds: ["auto", "composer-2"], - configOptions: [ - { - id: "session-model", - name: "Model", - category: "model", - type: "select", - currentValue: null, - options: [ - { value: "auto", label: "Auto", description: null, groupId: null, groupLabel: null }, - { value: "composer-2", label: "Composer 2", description: null, groupId: null, groupLabel: null }, - ], - }, - { - id: "session-mode", - name: "Mode", - category: "mode", - type: "select", - currentValue: null, - options: [ - { value: "edit", label: "Edit", description: null, groupId: null, groupLabel: null }, - { value: "plan", label: "Plan", description: null, groupId: null, groupLabel: null }, - ], - }, - ], - }); - }); -}); diff --git a/apps/desktop/src/main/services/chat/acpConfigState.ts b/apps/desktop/src/main/services/chat/acpConfigState.ts deleted file mode 100644 index 543f302c0..000000000 --- a/apps/desktop/src/main/services/chat/acpConfigState.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { SessionConfigOption, SessionConfigSelectOption, SessionConfigSelectGroup } from "@agentclientprotocol/sdk"; -import type { AgentChatCursorConfigOption } from "../../../shared/types"; - -type SessionConfigSelectOptionState = Extract; - -export type AcpConfigSnapshot = { - modeConfigId: string | null; - currentModeId: string | null; - availableModeIds: string[]; - modelConfigId: string | null; - currentModelId: string | null; - availableModelIds: string[]; - configOptions: AgentChatCursorConfigOption[]; -}; - -function normalizeText(value: unknown): string { - return String(value ?? "").trim(); -} - -function optionLooksLikeCategory(option: SessionConfigOption, category: "mode" | "model"): boolean { - if (option.category === category) return true; - const id = normalizeText(option.id).toLowerCase(); - const name = normalizeText(option.name).toLowerCase(); - if (category === "mode") { - return /\bmode\b/.test(id) || /\bmode\b/.test(name); - } - return /\bmodels?\b/.test(id) || /\bmodels?\b/.test(name); -} - -function listSelectOptionValues( - option: SessionConfigSelectOptionState | null | undefined, -): string[] { - if (!option || option.type !== "select") return []; - const out: string[] = []; - const append = (entry: SessionConfigSelectOption) => { - const value = normalizeText(entry.value); - if (value.length) out.push(value); - }; - for (const entry of option.options) { - if (!entry) continue; - if (Array.isArray((entry as SessionConfigSelectGroup).options)) { - for (const nested of (entry as SessionConfigSelectGroup).options) { - append(nested); - } - continue; - } - append(entry as SessionConfigSelectOption); - } - return out; -} - -function normalizeSelectCurrentValue( - option: SessionConfigSelectOptionState | null | undefined, -): string | null { - if (!option || option.type !== "select") return null; - const currentValue = normalizeText(option.currentValue); - if (!currentValue.length) return null; - const allowedValues = listSelectOptionValues(option); - if (allowedValues.length === 0) return currentValue; - return allowedValues.includes(currentValue) ? currentValue : null; -} - -function normalizeOptionCategory(option: SessionConfigOption): string | null { - const category = normalizeText(option.category); - return category.length ? category : null; -} - -function normalizeConfigOptions( - configOptions: SessionConfigOption[] | null | undefined, -): AgentChatCursorConfigOption[] { - if (!configOptions?.length) return []; - const normalized: AgentChatCursorConfigOption[] = []; - - for (const option of configOptions) { - const id = normalizeText(option.id); - const name = normalizeText(option.name); - if (!id.length || !name.length) continue; - - const description = normalizeText(option.description); - const category = normalizeOptionCategory(option); - - if (option.type === "boolean") { - normalized.push({ - id, - name, - ...(description.length ? { description } : {}), - ...(category ? { category } : {}), - type: "boolean", - currentValue: option.currentValue, - }); - continue; - } - - if (option.type !== "select") continue; - - const selectOptions = option.options.flatMap((entry) => { - if (!entry) return []; - if (Array.isArray((entry as SessionConfigSelectGroup).options)) { - const group = entry as SessionConfigSelectGroup; - const groupId = normalizeText(group.group); - const groupLabel = normalizeText(group.name); - return group.options.map((nested) => ({ - value: normalizeText(nested.value), - label: normalizeText(nested.name), - description: normalizeText(nested.description) || null, - groupId: groupId || null, - groupLabel: groupLabel || null, - })); - } - - const single = entry as SessionConfigSelectOption; - return [{ - value: normalizeText(single.value), - label: normalizeText(single.name), - description: normalizeText(single.description) || null, - groupId: null, - groupLabel: null, - }]; - }).filter((entry) => entry.value.length > 0 && entry.label.length > 0); - - normalized.push({ - id, - name, - ...(description.length ? { description } : {}), - ...(category ? { category } : {}), - type: "select", - currentValue: normalizeSelectCurrentValue(option), - options: selectOptions, - }); - } - - return normalized; -} - -function findSelectOption( - configOptions: SessionConfigOption[] | null | undefined, - category: "mode" | "model", -): SessionConfigSelectOptionState | null { - if (!configOptions?.length) return null; - for (const option of configOptions) { - if (option.type !== "select") continue; - if (optionLooksLikeCategory(option, category)) { - return option as SessionConfigSelectOptionState; - } - } - return null; -} - -export function readAcpConfigSnapshot( - configOptions: SessionConfigOption[] | null | undefined, -): AcpConfigSnapshot { - const modeOption = findSelectOption(configOptions, "mode"); - const modelOption = findSelectOption(configOptions, "model"); - - return { - modeConfigId: modeOption ? normalizeText(modeOption.id) || null : null, - currentModeId: normalizeSelectCurrentValue(modeOption), - availableModeIds: listSelectOptionValues(modeOption), - modelConfigId: modelOption ? normalizeText(modelOption.id) || null : null, - currentModelId: normalizeSelectCurrentValue(modelOption), - availableModelIds: listSelectOptionValues(modelOption), - configOptions: normalizeConfigOptions(configOptions), - }; -} diff --git a/apps/desktop/src/main/services/chat/acpEventMapper.test.ts b/apps/desktop/src/main/services/chat/acpEventMapper.test.ts deleted file mode 100644 index 640178937..000000000 --- a/apps/desktop/src/main/services/chat/acpEventMapper.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { mapAcpSessionNotificationToChatEvents } from "./acpEventMapper"; - -describe("mapAcpSessionNotificationToChatEvents", () => { - it("suppresses duplicate current mode notices when ACP repeats the same mode", () => { - const events = mapAcpSessionNotificationToChatEvents({ - sessionId: "cursor-session-1", - update: { - sessionUpdate: "current_mode_update", - currentModeId: "plan", - }, - } as any, { - turnId: "turn-1", - previousModeId: "plan", - }); - - expect(events).toEqual([]); - }); - - it("emits a notice when the current mode actually changes", () => { - const events = mapAcpSessionNotificationToChatEvents({ - sessionId: "cursor-session-1", - update: { - sessionUpdate: "current_mode_update", - currentModeId: "ask", - }, - } as any, { - turnId: "turn-1", - previousModeId: "plan", - }); - - expect(events).toEqual([ - { - type: "system_notice", - noticeKind: "info", - message: "Agent mode: ask", - turnId: "turn-1", - }, - ]); - }); - - it("emits a memory notice for completed memory_add results", () => { - const events = mapAcpSessionNotificationToChatEvents({ - sessionId: "cursor-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - title: "memory_add", - kind: "other", - status: "completed", - rawOutput: { - saved: true, - durability: "candidate", - deduped: true, - mergedIntoId: "memory-42", - reason: "duplicate memory merged", - }, - }, - } as any, { - turnId: "turn-1", - }); - - expect(events).toContainEqual({ - type: "system_notice", - noticeKind: "memory", - message: "Saved to memory as candidate, not promoted", - detail: "Durability: candidate\nMerged with existing memory.\nMerged into: memory-42\nReason: duplicate memory merged", - turnId: "turn-1", - }); - }); - - it("emits a memory notice for rejected memory_add results", () => { - const events = mapAcpSessionNotificationToChatEvents({ - sessionId: "cursor-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - title: "memory_add", - kind: "other", - status: "completed", - rawOutput: { - saved: false, - reason: "memory write rejected", - }, - }, - } as any, { - turnId: "turn-1", - }); - - expect(events).toContainEqual({ - type: "system_notice", - noticeKind: "memory", - message: "Skipped memory write: memory write rejected", - turnId: "turn-1", - }); - }); - - it("emits a memory notice for memory_pin results", () => { - const events = mapAcpSessionNotificationToChatEvents({ - sessionId: "cursor-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - title: "memory_pin", - kind: "other", - status: "completed", - rawOutput: { - id: "memory-7", - pinned: true, - }, - }, - } as any, { - turnId: "turn-1", - }); - - expect(events).toContainEqual({ - type: "system_notice", - noticeKind: "memory", - message: "Pinned memory entry: memory-7", - turnId: "turn-1", - }); - }); - - it("maps initial tool_call notifications so ADE tools keep their names", () => { - const events = mapAcpSessionNotificationToChatEvents({ - sessionId: "cursor-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-1", - title: "memory_search", - kind: "other", - rawInput: { - query: "git stash", - }, - }, - } as any, { - turnId: "turn-1", - }); - - expect(events).toEqual([ - { - type: "tool_call", - tool: "memory_search", - args: { - query: "git stash", - title: "memory_search", - kind: "other", - }, - itemId: "tool-1", - turnId: "turn-1", - }, - ]); - }); - - it("maps rich ACP plans into plan events", () => { - const events = mapAcpSessionNotificationToChatEvents({ - sessionId: "cursor-session-1", - update: { - sessionUpdate: "plan", - entries: [ - { content: "Inspect the stash source", status: "completed", priority: "high" }, - { content: "Trace the Git UI label", status: "in_progress", priority: "high" }, - ], - }, - } as any, { - turnId: "turn-1", - }); - - expect(events).toEqual([ - { - type: "plan", - steps: [ - { text: "Inspect the stash source", status: "completed" }, - { text: "Trace the Git UI label", status: "in_progress" }, - ], - turnId: "turn-1", - }, - ]); - }); - - it("suppresses trivial single-step ACP plans", () => { - const events = mapAcpSessionNotificationToChatEvents({ - sessionId: "cursor-session-1", - update: { - sessionUpdate: "plan", - entries: [ - { content: "Git stash WIP label", status: "pending", priority: "medium" }, - ], - }, - } as any, { - turnId: "turn-1", - }); - - expect(events).toEqual([]); - }); -}); diff --git a/apps/desktop/src/main/services/chat/acpEventMapper.ts b/apps/desktop/src/main/services/chat/acpEventMapper.ts deleted file mode 100644 index 0cb784020..000000000 --- a/apps/desktop/src/main/services/chat/acpEventMapper.ts +++ /dev/null @@ -1,394 +0,0 @@ -import type { SessionNotification } from "@agentclientprotocol/sdk"; -import type { AgentChatEvent } from "../../../shared/types"; - -export function simplePathDiff(path: string, oldText: string | null | undefined, newText: string): string { - const oldLines = (oldText ?? "").split("\n"); - const newLines = newText.split("\n"); - const out: string[] = [`--- ${path}`, `+++ ${path}`, "@@"]; - for (const line of oldLines) out.push(`-${line}`); - for (const line of newLines) out.push(`+${line}`); - return out.join("\n"); -} - -function toolNameFromKind(kind: string | undefined, title: string): string { - const trimmedTitle = title.trim(); - if (trimmedTitle.length) { - if ( - trimmedTitle.startsWith("functions.") - || trimmedTitle.startsWith("multi_tool_use.") - || trimmedTitle.startsWith("web.") - || /^[A-Za-z][A-Za-z0-9_.-]{1,127}$/.test(trimmedTitle) - ) { - return trimmedTitle; - } - } - const k = (kind ?? "other").toLowerCase(); - if (k === "execute") return "bash"; - if (k === "read") return "read_file"; - if (k === "edit" || k === "delete" || k === "move") return "str_replace"; - if (k === "search") return "grep"; - if (k === "fetch") return "fetch"; - if (k === "think") return "think"; - if (trimmedTitle.length) { - return trimmedTitle.length > 96 ? `${trimmedTitle.slice(0, 93).trimEnd()}...` : trimmedTitle; - } - return k || "tool"; -} - -function normalizeToolName(value: string | undefined | null): string { - return String(value ?? "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "_"); -} - -function readObject(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record - : null; -} - -function parseJsonTextPayload(text: string): Record | null { - const trimmed = text.trim(); - if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null; - try { - const parsed = JSON.parse(trimmed); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - // ignore invalid JSON-ish tool text - } - return null; -} - -function extractToolPayload( - update: Extract, -): Record | null { - const rawOutput = readObject(update.rawOutput); - if (rawOutput) return rawOutput; - for (const block of update.content ?? []) { - if (block.type !== "content" || !("text" in block) || typeof block.text !== "string") continue; - const parsed = parseJsonTextPayload(block.text); - if (parsed) return parsed; - } - return null; -} - -function buildToolArgs(args: { - rawInput: unknown; - title?: string | null; - kind?: string | null; - locations?: ReadonlyArray<{ path?: string | null } | null> | null; -}): Record { - const parsed = readObject(args.rawInput); - const base = parsed ? { ...parsed } : {}; - const title = typeof args.title === "string" ? args.title.trim() : ""; - const kind = typeof args.kind === "string" ? args.kind.trim() : ""; - if (title.length && typeof base.title !== "string") { - base.title = title; - } - if (kind.length && typeof base.kind !== "string") { - base.kind = kind; - } - const paths = (args.locations ?? []) - .map((location) => typeof location?.path === "string" ? location.path.trim() : "") - .filter(Boolean); - if (paths.length === 1 && typeof base.path !== "string") { - base.path = paths[0]!; - } else if (paths.length > 1 && !Array.isArray(base.paths)) { - base.paths = paths; - } - return base; -} - -function buildCursorMemoryNotice(toolName: string, payload: Record | null): AgentChatEvent | null { - const normalizedTool = normalizeToolName(toolName); - if (normalizedTool.includes("memory_pin")) { - const pinned = payload?.pinned; - const id = typeof payload?.id === "string" ? payload.id.trim() : ""; - return { - type: "system_notice", - noticeKind: "memory", - message: id.length - ? `Pinned memory entry${pinned === false ? " update failed" : ""}: ${id}` - : "Pinned memory entry", - }; - } - if (!normalizedTool.includes("memory_add")) return null; - - const saved = payload?.saved; - const reason = typeof payload?.reason === "string" ? payload.reason.trim() : ""; - const durability = typeof payload?.durability === "string" ? payload.durability.trim() : ""; - const deduped = payload?.deduped === true; - const mergedIntoId = typeof payload?.mergedIntoId === "string" ? payload.mergedIntoId.trim() : ""; - - if (saved !== true) { - return { - type: "system_notice", - noticeKind: "memory", - message: `Skipped memory write: ${reason || "write rejected"}`, - }; - } - - const detailLines = [ - durability.length ? `Durability: ${durability}` : null, - deduped ? "Merged with existing memory." : null, - mergedIntoId.length ? `Merged into: ${mergedIntoId}` : null, - reason.length ? `Reason: ${reason}` : null, - ].filter((line): line is string => Boolean(line)); - - return { - type: "system_notice", - noticeKind: "memory", - message: durability === "candidate" - ? "Saved to memory as candidate, not promoted" - : "Saved to memory as promoted knowledge", - ...(detailLines.length ? { detail: detailLines.join("\n") } : {}), - }; -} - -export type AcpMapperMeta = { - turnId: string; - previousModeId?: string | null; -}; - -export type AcpTerminalSnapshot = { - output: string; - cwd: string; - commandLine: string; - exited: boolean; - exitCode: number | null; - truncated: boolean; -}; - -/** Command item ids look like `${toolCallId}:term:${terminalId}`. */ -export function parseAcpTerminalIdFromCommandItemId(itemId: string): string | null { - const marker = ":term:"; - const i = itemId.lastIndexOf(marker); - if (i < 0) return null; - const id = itemId.slice(i + marker.length).trim(); - return id.length ? id : null; -} - -export function mapAcpSessionNotificationToChatEvents( - note: SessionNotification, - meta: AcpMapperMeta, - resolveTerminal?: (terminalId: string) => AcpTerminalSnapshot | null | undefined, -): AgentChatEvent[] { - const { sessionId: _s, update } = note; - const turnId = meta.turnId; - const out: AgentChatEvent[] = []; - - switch (update.sessionUpdate) { - case "agent_message_chunk": { - const c = update.content; - if (c?.type === "text" && typeof c.text === "string" && c.text.length) { - out.push({ type: "text", text: c.text, turnId }); - } - break; - } - case "tool_call": { - const u = update; - out.push({ - type: "tool_call", - tool: toolNameFromKind(u.kind ?? undefined, u.title ?? ""), - args: buildToolArgs({ - rawInput: u.rawInput, - title: u.title, - kind: u.kind ?? undefined, - locations: u.locations, - }), - itemId: u.toolCallId, - turnId, - }); - break; - } - case "tool_call_update": { - const u = update; - const itemId = u.toolCallId; - const tool = toolNameFromKind(u.kind ?? undefined, u.title ?? ""); - const status = u.status ?? "pending"; - if (status === "pending" || status === "in_progress") { - out.push({ - type: "tool_call", - tool, - args: buildToolArgs({ - rawInput: u.rawInput, - title: u.title, - kind: u.kind ?? undefined, - locations: u.locations, - }), - itemId, - turnId, - }); - } - if (u.content) { - for (const block of u.content) { - if (block.type === "diff") { - const path = block.path ?? "file"; - const diff = simplePathDiff(path, block.oldText, block.newText); - const kind = block.oldText == null || block.oldText === "" ? "create" : "modify"; - out.push({ - type: "file_change", - path, - diff, - kind, - itemId, - turnId, - status: status === "failed" ? "failed" : status === "completed" ? "completed" : "running", - }); - } else if (block.type === "terminal" && typeof block.terminalId === "string") { - const snap = resolveTerminal?.(block.terminalId); - const truncatedNote = snap?.truncated ? "\n…(output truncated)" : ""; - const output = snap - ? `${snap.output}${truncatedNote}` - : `(terminal ${block.terminalId})`; - const commandLine = snap?.commandLine ?? u.title ?? "shell"; - const cwd = snap?.cwd ?? ""; - let cmdStatus: "running" | "completed" | "failed" = "running"; - if (status === "failed") cmdStatus = "failed"; - else if (status === "completed") cmdStatus = "completed"; - else if (snap?.exited) cmdStatus = snap.exitCode === 0 ? "completed" : "failed"; - out.push({ - type: "command", - command: commandLine, - cwd, - output, - itemId: `${itemId}:term:${block.terminalId}`, - turnId, - status: cmdStatus, - ...(snap?.exited ? { exitCode: snap.exitCode } : {}), - }); - } else if (block.type === "content" && "text" in block && typeof (block as { text?: string }).text === "string") { - out.push({ - type: "tool_result", - tool, - result: { text: (block as { text: string }).text }, - itemId: `${itemId}:c`, - logicalItemId: itemId, - turnId, - status: status === "failed" ? "failed" : "completed", - }); - } - } - } - if (status === "completed" || status === "failed") { - if (!u.content?.length) { - out.push({ - type: "tool_result", - tool, - result: u.rawOutput ?? { title: u.title }, - itemId, - turnId, - status: status === "failed" ? "failed" : "completed", - }); - } - const memoryNotice = buildCursorMemoryNotice( - [u.title, tool].filter(Boolean).join(" "), - extractToolPayload(u), - ); - if (memoryNotice) { - out.push({ - ...memoryNotice, - turnId, - }); - } - } - break; - } - case "plan": { - const steps: Extract["steps"] = []; - for (const entry of update.entries ?? []) { - const text = typeof entry?.content === "string" ? entry.content.trim() : ""; - if (!text.length) continue; - const mappedStatus: Extract["steps"][number]["status"] = - entry?.status === "completed" || entry?.status === "in_progress" || entry?.status === "pending" - ? entry.status - : "pending"; - steps.push({ - text, - status: mappedStatus, - }); - } - const isTrivialSingleStepPlan = steps.length === 1 - && steps[0]!.status === "pending" - && steps[0]!.text.length <= 80 - && !steps[0]!.text.includes("\n"); - if (steps.length && !isTrivialSingleStepPlan) { - out.push({ type: "plan", steps, turnId }); - } - break; - } - case "current_mode_update": { - const mode = typeof update.currentModeId === "string" ? update.currentModeId.trim() : ""; - const previousMode = typeof meta.previousModeId === "string" ? meta.previousModeId.trim() : ""; - if (mode.length && mode !== previousMode) { - out.push({ - type: "system_notice", - noticeKind: "info", - message: `Agent mode: ${mode}`, - turnId, - }); - } - break; - } - case "usage_update": { - // Token usage sometimes streamed — final usage also on PromptResponse - break; - } - default: - break; - } - - return out; -} - -export function mapStopReasonToTerminalEvents(args: { - stopReason: "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled"; - turnId: string; - model?: string; - modelId?: import("../../../shared/types").ModelId; - usage?: { - inputTokens?: number | null; - outputTokens?: number | null; - cacheReadTokens?: number | null; - cacheCreationTokens?: number | null; - }; -}): AgentChatEvent[] { - const { stopReason, turnId, model, modelId, usage } = args; - const out: AgentChatEvent[] = []; - - if (stopReason === "refusal") { - out.push({ type: "error", message: "The model refused this request.", turnId }); - } - - if (stopReason === "max_tokens" || stopReason === "max_turn_requests") { - out.push({ - type: "system_notice", - noticeKind: "info", - message: - stopReason === "max_tokens" - ? "Context or output limit reached for this turn." - : "Maximum agent turns reached for this prompt.", - turnId, - }); - } - - let doneStatus: "interrupted" | "failed" | "completed"; - if (stopReason === "cancelled") { - doneStatus = "interrupted"; - } else if (stopReason === "refusal") { - doneStatus = "failed"; - } else { - doneStatus = "completed"; - } - - out.push({ - type: "done", - turnId, - status: doneStatus, - ...(model ? { model } : {}), - ...(modelId ? { modelId } : {}), - ...(usage ? { usage } : {}), - }); - - return out; -} diff --git a/apps/desktop/src/main/services/chat/acpHostClient.ts b/apps/desktop/src/main/services/chat/acpHostClient.ts deleted file mode 100644 index 453a4c07b..000000000 --- a/apps/desktop/src/main/services/chat/acpHostClient.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import path from "node:path"; -import { - type CreateTerminalRequest, - type KillTerminalRequest, - type ReadTextFileRequest, - type ReadTextFileResponse, - type ReleaseTerminalRequest, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, - type TerminalOutputRequest, - type TerminalOutputResponse, - type WaitForTerminalExitRequest, - type WaitForTerminalExitResponse, - type WriteTextFileRequest, - type WriteTextFileResponse, - type Client, -} from "@agentclientprotocol/sdk"; -import { - hasNullByte, - readFileWithinRootSecure, - resolvePathWithinRoot, - secureWriteTextAtomicWithinRoot, - destroyChildProcessStreams, - signalChildProcessTree, - terminateChildProcessTree, -} from "../shared/utils"; -import { resolveCliSpawnInvocation } from "../shared/processExecution"; - -/** Bridge hooks for an ACP host such as Factory Droid. */ -export type AcpHostBridge = { - onPermission: ((req: RequestPermissionRequest) => Promise) | null; - onSessionUpdate: ((n: SessionNotification) => void) | null; - getRootPath: () => string; - getDirtyFileText: ((absPath: string) => string | undefined | Promise) | null; - onTerminalOutputDelta: ((terminalId: string, acpSessionId: string) => void) | null; - flushTerminalOutput: ((terminalId: string, acpSessionId: string) => void) | null; - onTerminalDisposed: ((terminalId: string) => void) | null; -}; - -export type AcpHostTermState = { - proc: ChildProcessWithoutNullStreams; - output: string; - truncated: boolean; - limit: number; - cwd: string; - command: string; - exited: boolean; - exitCode: number | null; - exitSignal: NodeJS.Signals | null; - acpSessionId: string; -}; - -function mergeEnvVars( - base: NodeJS.ProcessEnv, - extra?: Array<{ name: string; value: string }>, -): NodeJS.ProcessEnv { - const out = { ...base }; - if (!extra) return out; - for (const { name, value } of extra) { - if (name) out[name] = value; - } - return out; -} - -function appendOutput(state: AcpHostTermState, chunk: Buffer | string): void { - const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - state.output += text; - const lim = state.limit > 0 ? state.limit : 512 * 1024; - if (state.output.length > lim) { - state.output = state.output.slice(state.output.length - lim); - state.truncated = true; - } -} - -function applyLineLimit(text: string, line?: number | null, limit?: number | null): string { - const lines = text.split(/\r?\n/); - const start = typeof line === "number" && line > 0 ? line - 1 : 0; - const max = typeof limit === "number" && limit > 0 ? limit : lines.length; - return lines.slice(start, start + max).join("\n"); -} - -async function resolveDirtyText( - bridge: AcpHostBridge, - filePath: string, -): Promise { - const raw = bridge.getDirtyFileText?.(filePath); - const v = await Promise.resolve(raw); - return typeof v === "string" ? v : undefined; -} - -export type CreateAcpHostClientOptions = { - /** Log prefix, e.g. `[DroidAcpPool]` */ - logPrefix: string; -}; - -const WAIT_FOR_TERMINAL_EXIT_MAX_MS = 5 * 60_000; - -/** - * ACP `Client` implementation for Factory Droid (`droid exec --output-format acp`). - */ -export function createAcpHostClient( - bridge: AcpHostBridge, - terminals: Map, - options: CreateAcpHostClientOptions, -): Client { - const { logPrefix } = options; - return { - async requestPermission(params: RequestPermissionRequest): Promise { - const handler = bridge.onPermission; - if (!handler) { - return { outcome: { outcome: "cancelled" } }; - } - return handler(params); - }, - - async sessionUpdate(params: SessionNotification): Promise { - bridge.onSessionUpdate?.(params); - }, - - async readTextFile(params: ReadTextFileRequest): Promise { - const p = params.path.trim(); - if (!path.isAbsolute(p)) { - throw new Error("ACP read_text_file requires an absolute path."); - } - const root = bridge.getRootPath(); - let buf: Buffer; - try { - buf = readFileWithinRootSecure(root, p); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err?.code === "ENOENT") { - const dirty = await resolveDirtyText(bridge, p); - if (dirty !== undefined) return { content: applyLineLimit(dirty, params.line, params.limit) }; - } - throw e; - } - if (hasNullByte(buf)) { - throw new Error("Binary files cannot be read as text."); - } - let text = buf.toString("utf8"); - const dirty = await resolveDirtyText(bridge, p); - if (dirty !== undefined) text = dirty; - return { content: applyLineLimit(text, params.line, params.limit) }; - }, - - async writeTextFile(params: WriteTextFileRequest): Promise { - const p = params.path.trim(); - if (!path.isAbsolute(p)) { - throw new Error("ACP write_text_file requires an absolute path."); - } - const root = bridge.getRootPath(); - secureWriteTextAtomicWithinRoot(root, p, params.content); - return {}; - }, - - async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> { - const root = bridge.getRootPath(); - const requested = (params.cwd && params.cwd.trim()) || root; - let cwd = root; - try { - cwd = resolvePathWithinRoot(root, requested, { allowMissing: true }); - } catch (e) { - console.warn(`${logPrefix} terminal cwd rejected (outside lane root), using root:`, e); - } - const termId = randomUUID(); - const limit = typeof params.outputByteLimit === "number" && params.outputByteLimit > 0 - ? params.outputByteLimit - : 512 * 1024; - const env = mergeEnvVars(process.env, params.env ?? undefined); - const invocation = resolveCliSpawnInvocation(params.command, params.args ?? [], env); - const proc = spawn(invocation.command, invocation.args, { - cwd, - env, - detached: process.platform !== "win32", - stdio: ["pipe", "pipe", "pipe"], - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - }); - proc.on("error", (err) => { - console.error(`${logPrefix} terminal process error for termId=${termId}:`, err); - const t = terminals.get(termId); - if (t && !t.exited) { - t.exited = true; - t.exitCode = -1; - bridge.flushTerminalOutput?.(termId, params.sessionId); - } - }); - const state: AcpHostTermState = { - proc, - output: "", - truncated: false, - limit, - cwd, - command: `${params.command} ${(params.args ?? []).join(" ")}`.trim(), - exited: false, - exitCode: null, - exitSignal: null, - acpSessionId: params.sessionId, - }; - proc.stdout?.on("data", (d) => { - appendOutput(state, d); - bridge.onTerminalOutputDelta?.(termId, state.acpSessionId); - }); - proc.stderr?.on("data", (d) => { - appendOutput(state, d); - bridge.onTerminalOutputDelta?.(termId, state.acpSessionId); - }); - proc.on("close", (code, signal) => { - state.exited = true; - state.exitCode = code; - state.exitSignal = signal; - bridge.flushTerminalOutput?.(termId, state.acpSessionId); - }); - terminals.set(termId, state); - return { terminalId: termId }; - }, - - async terminalOutput(params: TerminalOutputRequest): Promise { - const t = terminals.get(params.terminalId); - if (!t) { - return { output: "", truncated: false }; - } - return { - output: t.output, - truncated: t.truncated, - ...(t.exited ? { exitStatus: { exitCode: t.exitCode, signal: t.exitSignal } } : {}), - }; - }, - - async waitForTerminalExit(params: WaitForTerminalExitRequest): Promise { - const t = terminals.get(params.terminalId); - if (!t) { - return { exitCode: -1, signal: null }; - } - if (!t.exited) { - let killTimer: ReturnType | undefined; - const closed = new Promise((resolve) => { - t.proc.once("close", () => { - if (killTimer !== undefined) clearTimeout(killTimer); - resolve(); - }); - }); - const timedOut = new Promise((resolve) => { - killTimer = setTimeout(() => { - try { - if (!t.exited) { - killTimer = terminateChildProcessTree(t.proc, killTimer ?? null, 1_500); - } - } catch { - // ignore - } - console.warn( - `${logPrefix} waitForTerminalExit exceeded ${WAIT_FOR_TERMINAL_EXIT_MAX_MS}ms; initiated tree termination`, - ); - resolve(); - }, WAIT_FOR_TERMINAL_EXIT_MAX_MS); - }); - await Promise.race([closed, timedOut]); - if (!t.exited) { - await new Promise((resolve) => { - const tmo = setTimeout(resolve, 15_000); - t.proc.once("close", () => { - clearTimeout(tmo); - resolve(); - }); - }); - } - } - return { exitCode: t.exitCode ?? -1, signal: t.exitSignal }; - }, - - async killTerminal(params: KillTerminalRequest): Promise { - const t = terminals.get(params.terminalId); - if (t && !t.exited) { - try { - signalChildProcessTree(t.proc, "SIGTERM"); - } catch { - // ignore - } - // Escalate to SIGKILL if the child has not exited within the grace window. - const escalation = setTimeout(() => { - if (!t.exited) { - try { - signalChildProcessTree(t.proc, "SIGKILL"); - } catch { - // ignore - } - } - }, 1_500); - t.proc.once("close", () => clearTimeout(escalation)); - } - }, - - async releaseTerminal(params: ReleaseTerminalRequest): Promise { - const t = terminals.get(params.terminalId); - if (t) { - try { - if (!t.exited) signalChildProcessTree(t.proc, "SIGKILL"); - } catch { - // ignore - } - destroyChildProcessStreams(t.proc); - const id = params.terminalId; - terminals.delete(id); - bridge.onTerminalDisposed?.(id); - } - }, - }; -} diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 073d9f60d..a674851ef 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -253,10 +253,6 @@ import { } from "../opencode/openCodeRuntime"; import { peekOpenCodeInventoryCache, probeOpenCodeProviderInventory } from "../opencode/openCodeInventory"; import { inspectLocalProvider } from "../ai/localModelDiscovery"; -import type { - PermissionOption, - RequestPermissionResponse, -} from "@agentclientprotocol/sdk"; import { resolveDroidExecutable } from "../ai/droidExecutable"; import { acquireCursorSdkConnection, @@ -320,7 +316,7 @@ import { type CursorSdkRuntime, } from "./cursorSdkSystemPrompt"; import { promises as fsPromises } from "node:fs"; -import { mapStopReasonToTerminalEvents } from "./acpEventMapper"; +import { mapStopReasonToTerminalEvents } from "./stopReasonEvents"; import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { getApiKey } from "../ai/apiKeyStore"; import type { createMissionService } from "../missions/missionService"; @@ -386,8 +382,6 @@ type PersistedChatState = { capabilityMode?: CtoCapabilityMode; completion?: AgentChatCompletionReport | null; threadId?: string; - /** Legacy ACP session id for Droid resume across app restarts (best-effort). */ - acpSessionId?: string; /** Factory Droid SDK session id for Droid resume across app restarts (best-effort). */ droidSdkSessionId?: string; sdkSessionId?: string; @@ -721,17 +715,10 @@ type OpenCodeRuntime = { }; type CursorPermissionWaiter = - | { - sdkHook?: false; - options: PermissionOption[]; - resolve: (value: RequestPermissionResponse) => void; - } - | { - sdkHook: true; - toolName: string; - options: PermissionOption[]; - resolve: (value: CursorSdkHookDecision) => void; - }; + { + toolName: string; + resolve: (value: CursorSdkHookDecision) => void; + }; type CursorCloudActiveRun = { agentId: string; @@ -794,11 +781,7 @@ type DroidRuntime = { type ChatRuntime = CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime; function cancelCursorPermissionWaiter(waiter: CursorPermissionWaiter, reason: string): void { - if (waiter.sdkHook) { - waiter.resolve(denyCursorHook(reason)); - return; - } - waiter.resolve({ outcome: { outcome: "cancelled" } }); + waiter.resolve(denyCursorHook(reason)); } type DroidPermissionWaiter = { @@ -1662,7 +1645,7 @@ type PreparedSendMessage = { onDispatched?: () => void; turnId?: string; optimisticCursorTurnStart?: boolean; - optimisticAcpTurnStart?: boolean; + optimisticDroidTurnStart?: boolean; optimisticCodexTurnStart?: boolean; runtime?: AgentChatRuntime; cloudOverrides?: AgentChatCloudOverrides; @@ -2390,7 +2373,7 @@ function readErrorDetail(value: unknown): string | null { return null; } -function classifyAcpHostError( +function classifyProviderHostError( error: unknown, providerLabel: string, modelDisplayName: string, @@ -2777,7 +2760,7 @@ function classifyCursorSdkChatError( }, }; } - return classifyAcpHostError( + return classifyProviderHostError( error, args.cloud ? "Cursor Cloud" : "Cursor", args.modelDisplayName ?? "Cursor SDK", @@ -7401,9 +7384,7 @@ export function createAgentChatService(args: { : claudePointer?.sessionId; const droidSdkSessionId = provider === "droid" && typeof record.droidSdkSessionId === "string" && record.droidSdkSessionId.trim().length ? record.droidSdkSessionId.trim() - : provider === "droid" && typeof record.acpSessionId === "string" && record.acpSessionId.trim().length - ? record.acpSessionId.trim() - : undefined; + : undefined; const forkFromSdkSessionId = typeof record.forkFromSdkSessionId === "string" && record.forkFromSdkSessionId.trim().length ? record.forkFromSdkSessionId.trim() : undefined; @@ -7498,9 +7479,6 @@ export function createAgentChatService(args: { ...(typeof record.threadId === "string" && record.threadId.trim().length ? { threadId: record.threadId.trim() } : {}), - ...(typeof record.acpSessionId === "string" && record.acpSessionId.trim().length - ? { acpSessionId: record.acpSessionId.trim() } - : {}), ...(droidSdkSessionId ? { droidSdkSessionId } : {}), ...(sdkSessionId ? { sdkSessionId } : {}), ...(forkFromSdkSessionId ? { forkFromSdkSessionId } : {}), @@ -16099,33 +16077,6 @@ export function createAgentChatService(args: { const normalizeCursorSdkToolName = (name: string): string => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_"); - const mapChatDecisionToCursorPermission = ( - decision: AgentChatApprovalDecision | undefined, - options: PermissionOption[], - answers?: Record, - ): RequestPermissionResponse => { - // If the caller provided an explicit optionId (e.g. from a structured - // selection), resolve it directly instead of the coarse decision mapping. - if (answers) { - const explicit = Object.values(answers).flat()[0]; - const match = explicit ? options.find((o) => o.optionId === explicit) : undefined; - if (match) return { outcome: { outcome: "selected", optionId: match.optionId } }; - } - const pick = (kind: PermissionOption["kind"]) => options.find((o) => o.kind === kind)?.optionId; - if (decision === "cancel") return { outcome: { outcome: "cancelled" } }; - if (decision === "accept_for_session") { - const id = pick("allow_always") ?? pick("allow_once"); - if (id) return { outcome: { outcome: "selected", optionId: id } }; - } else if (decision === "accept") { - const id = pick("allow_once") ?? pick("allow_always"); - if (id) return { outcome: { outcome: "selected", optionId: id } }; - } else if (decision === "decline") { - const id = pick("reject_once") ?? pick("reject_always"); - if (id) return { outcome: { outcome: "selected", optionId: id } }; - } - return { outcome: { outcome: "cancelled" } }; - }; - const mapChatDecisionToCursorSdkHook = ( decision: AgentChatApprovalDecision | undefined, ): CursorSdkHookDecision => { @@ -17056,16 +17007,9 @@ export function createAgentChatService(args: { } const itemId = req.id || randomUUID(); - const options = [ - { kind: "allow_once", optionId: "allow_once" }, - { kind: "allow_always", optionId: "allow_always" }, - { kind: "reject_once", optionId: "reject_once" }, - ] as PermissionOption[]; return new Promise((outerResolve) => { runtime.permissionWaiters.set(itemId, { - sdkHook: true, toolName: req.toolName, - options, resolve: (decision) => { runtime.permissionWaiters.delete(itemId); outerResolve(decision); @@ -18504,7 +18448,7 @@ export function createAgentChatService(args: { } catch (error) { markSessionIdleWithFreshCache(managed); const descriptor = resolveSessionModelDescriptor(managed.session); - const classified = classifyAcpHostError( + const classified = classifyProviderHostError( error, "Factory Droid", descriptor?.displayName ?? managed.session.model ?? "Droid", @@ -18592,7 +18536,7 @@ export function createAgentChatService(args: { onDispatched, turnId, optimisticCursorTurnStart, - optimisticAcpTurnStart, + optimisticDroidTurnStart, optimisticCodexTurnStart, } = prepared; @@ -18692,7 +18636,7 @@ export function createAgentChatService(args: { resolvedAttachments, laneDirectiveKey, turnId, - optimisticDroidTurnStart: optimisticAcpTurnStart, + optimisticDroidTurnStart, onDispatched, }); return; @@ -18882,7 +18826,7 @@ export function createAgentChatService(args: { if (prepared.managed.session.provider === "cursor") { prepared.optimisticCursorTurnStart = true; } else { - prepared.optimisticAcpTurnStart = true; + prepared.optimisticDroidTurnStart = true; } emitChatEvent(prepared.managed, { type: "user_message", @@ -20341,19 +20285,10 @@ export function createAgentChatService(args: { return; } cursorRuntime.permissionWaiters.delete(itemId); - if (pending.sdkHook) { - if (resolvedDecision === "accept_for_session") { - cursorRuntime.sdkApprovedTools.add(normalizeCursorSdkToolName(pending.toolName)); - } - pending.resolve(mapChatDecisionToCursorSdkHook(resolvedDecision)); - emitPendingInputResolved(managed, { - itemId, - decision: resolvedDecision, - turnId: cursorRuntime.activeTurnId ?? null, - }); - return; + if (resolvedDecision === "accept_for_session") { + cursorRuntime.sdkApprovedTools.add(normalizeCursorSdkToolName(pending.toolName)); } - pending.resolve(mapChatDecisionToCursorPermission(resolvedDecision, pending.options, answers)); + pending.resolve(mapChatDecisionToCursorSdkHook(resolvedDecision)); emitPendingInputResolved(managed, { itemId, decision: resolvedDecision, diff --git a/apps/desktop/src/main/services/chat/stopReasonEvents.ts b/apps/desktop/src/main/services/chat/stopReasonEvents.ts new file mode 100644 index 000000000..aef1fb96c --- /dev/null +++ b/apps/desktop/src/main/services/chat/stopReasonEvents.ts @@ -0,0 +1,51 @@ +import type { AgentChatEvent, ModelId } from "../../../shared/types"; + +export function mapStopReasonToTerminalEvents(args: { + stopReason: "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled"; + turnId: string; + model?: string; + modelId?: ModelId; + usage?: { + inputTokens?: number | null; + outputTokens?: number | null; + cacheReadTokens?: number | null; + cacheCreationTokens?: number | null; + }; +}): AgentChatEvent[] { + const { stopReason, turnId, model, modelId, usage } = args; + const out: AgentChatEvent[] = []; + + if (stopReason === "refusal") { + out.push({ type: "error", message: "The model refused this request.", turnId }); + } + + if (stopReason === "max_tokens" || stopReason === "max_turn_requests") { + out.push({ + type: "system_notice", + noticeKind: "info", + message: + stopReason === "max_tokens" + ? "Context or output limit reached for this turn." + : "Maximum agent turns reached for this prompt.", + turnId, + }); + } + + const doneStatus = + stopReason === "cancelled" + ? "interrupted" + : stopReason === "refusal" + ? "failed" + : "completed"; + + out.push({ + type: "done", + turnId, + status: doneStatus, + ...(model ? { model } : {}), + ...(modelId ? { modelId } : {}), + ...(usage ? { usage } : {}), + }); + + return out; +} diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 8601aaa1b..484611ea9 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3042,8 +3042,13 @@ describe("laneService delete teardown + cancellation + streaming", () => { const events: any[] = []; const fake = makeFakeServices(); let releaseStop: (() => void) | null = null; + let stopStarted: (() => void) | null = null; + const stopStartedPromise = new Promise((resolve) => { + stopStarted = resolve; + }); fake.processService.stopAll.mockImplementation(async () => { fake.calls.push("stop_processes"); + stopStarted?.(); await new Promise((resolve) => { releaseStop = resolve; }); @@ -3053,7 +3058,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); const deletePromise = service.delete({ laneId: "lane-child", deleteBranch: false, force: true }); - await new Promise((r) => setTimeout(r, 10)); + await stopStartedPromise; expect(service.hasRunningDelete()).toBe(true); expect(releaseStop).not.toBeNull(); diff --git a/apps/desktop/src/shared/modelRegistry.test.ts b/apps/desktop/src/shared/modelRegistry.test.ts index c1fdbbd8f..96b4951af 100644 --- a/apps/desktop/src/shared/modelRegistry.test.ts +++ b/apps/desktop/src/shared/modelRegistry.test.ts @@ -281,7 +281,7 @@ describe("modelRegistry", () => { expect(descriptor?.providerModelId).toBe("custom:gpt-5.4(xhigh)"); }); - it("formats Droid custom thinking models with the ACP display label", () => { + it("formats Droid custom thinking models with the expected display label", () => { const descriptor = getModelById("droid/custom:claude-sonnet-4-6-thinking-32000"); expect(descriptor).toBeTruthy(); expect(descriptor?.displayName).toBe("Claude Sonnet 4.6 (High)");