From 7165d9f45032ae599e4044c62f48f1ba4e9a7018 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 00:29:41 -0700 Subject: [PATCH 01/82] Add Cursor provider session and model selection support - Introduce Cursor ACP adapter and model selection probe - Preserve cursor session resume state across model changes - Propagate provider and runtime tool metadata through orchestration and UI Made-with: Cursor --- apps/server/scripts/acp-mock-agent.mjs | 279 +++ .../cursor-acp-model-selection-probe.ts | 135 ++ .../server/src/git/Services/TextGeneration.ts | 2 +- .../Layers/ProviderCommandReactor.test.ts | 100 ++ .../Layers/ProviderCommandReactor.ts | 7 +- .../Layers/ProviderRuntimeIngestion.test.ts | 56 + .../Layers/ProviderRuntimeIngestion.ts | 1 + .../src/provider/Layers/CursorAdapter.test.ts | 484 +++++ .../src/provider/Layers/CursorAdapter.ts | 1589 +++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 23 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../provider/Layers/ProviderService.test.ts | 8 +- .../Layers/ProviderSessionDirectory.ts | 2 +- .../src/provider/Services/CursorAdapter.ts | 12 + .../src/provider/Services/ProviderAdapter.ts | 2 +- apps/server/src/provider/acp/AcpErrors.ts | 24 + .../provider/acp/AcpJsonRpcConnection.test.ts | 51 + .../src/provider/acp/AcpJsonRpcConnection.ts | 250 +++ apps/server/src/provider/acp/AcpTypes.ts | 147 ++ .../provider/acp/CursorAcpCliProbe.test.ts | 34 + apps/server/src/provider/acp/index.ts | 3 + apps/server/src/serverLayers.ts | 5 + apps/server/src/serverSettings.ts | 2 +- apps/web/src/components/ChatView.tsx | 47 +- .../components/KeybindingsToast.browser.tsx | 1 + .../CompactComposerControlsMenu.browser.tsx | 75 +- .../components/chat/CursorTraitsPicker.tsx | 238 +++ .../src/components/chat/MessagesTimeline.tsx | 8 +- .../chat/ProviderModelPicker.browser.tsx | 40 +- .../components/chat/ProviderModelPicker.tsx | 68 +- .../chat/composerProviderRegistry.test.tsx | 34 + .../chat/composerProviderRegistry.tsx | 27 + apps/web/src/composerDraftStore.ts | 41 +- apps/web/src/modelSelection.ts | 13 + apps/web/src/routes/_chat.settings.tsx | 21 +- apps/web/src/session-logic.test.ts | 164 +- apps/web/src/session-logic.ts | 126 +- apps/web/src/store.ts | 2 +- packages/contracts/src/cursorCliModels.json | 343 ++++ packages/contracts/src/model.ts | 113 +- packages/contracts/src/orchestration.ts | 17 +- packages/contracts/src/provider.test.ts | 20 + packages/contracts/src/providerRuntime.ts | 2 + packages/contracts/src/settings.ts | 30 + packages/shared/src/model.test.ts | 214 +++ packages/shared/src/model.ts | 645 +++++++ scripts/cursor-agent-models-probe.mjs | 115 ++ 47 files changed, 5547 insertions(+), 76 deletions(-) create mode 100644 apps/server/scripts/acp-mock-agent.mjs create mode 100644 apps/server/scripts/cursor-acp-model-selection-probe.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapter.ts create mode 100644 apps/server/src/provider/Services/CursorAdapter.ts create mode 100644 apps/server/src/provider/acp/AcpErrors.ts create mode 100644 apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts create mode 100644 apps/server/src/provider/acp/AcpJsonRpcConnection.ts create mode 100644 apps/server/src/provider/acp/AcpTypes.ts create mode 100644 apps/server/src/provider/acp/CursorAcpCliProbe.test.ts create mode 100644 apps/server/src/provider/acp/index.ts create mode 100644 apps/web/src/components/chat/CursorTraitsPicker.tsx create mode 100644 packages/contracts/src/cursorCliModels.json create mode 100644 scripts/cursor-agent-models-probe.mjs diff --git a/apps/server/scripts/acp-mock-agent.mjs b/apps/server/scripts/acp-mock-agent.mjs new file mode 100644 index 00000000000..a261ed692da --- /dev/null +++ b/apps/server/scripts/acp-mock-agent.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +/** + * Minimal NDJSON JSON-RPC "agent" for ACP client tests. + * Reads stdin lines; writes responses/notifications to stdout. + */ +import * as readline from "node:readline"; +import { appendFileSync } from "node:fs"; + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; +const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const sessionId = "mock-session-1"; +let currentModeId = "ask"; +let nextRequestId = 1; +const availableModes = [ + { + id: "ask", + name: "Ask", + description: "Request permission before making any changes", + }, + { + id: "architect", + name: "Architect", + description: "Design and plan software systems without implementation", + }, + { + id: "code", + name: "Code", + description: "Write and modify code with full tool access", + }, +]; +const pendingPermissionRequests = new Map(); + +function send(obj) { + process.stdout.write(`${JSON.stringify(obj)}\n`); +} + +function modeState() { + return { + currentModeId, + availableModes, + }; +} + +function sendSessionUpdate(update, session = sessionId) { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: session, + update, + }, + }); +} + +rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch { + return; + } + if (!msg || typeof msg !== "object") return; + if (requestLogPath) { + appendFileSync(requestLogPath, `${JSON.stringify(msg)}\n`, "utf8"); + } + + const id = msg.id; + const method = msg.method; + + if (method === undefined && id !== undefined && pendingPermissionRequests.has(id)) { + const pending = pendingPermissionRequests.get(id); + pendingPermissionRequests.delete(id); + sendSessionUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId: pending.toolCallId, + title: "Terminal", + kind: "execute", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: '{ "name": "t3" }', + stderr: "", + }, + }, + pending.sessionId, + ); + sendSessionUpdate( + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from mock" }, + }, + pending.sessionId, + ); + send({ + jsonrpc: "2.0", + id: pending.promptRequestId, + result: { stopReason: "end_turn" }, + }); + return; + } + + if (method === "initialize" && id !== undefined) { + send({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + }, + }); + return; + } + + if (method === "authenticate" && id !== undefined) { + send({ jsonrpc: "2.0", id, result: { authenticated: true } }); + return; + } + + if (method === "session/new" && id !== undefined) { + send({ + jsonrpc: "2.0", + id, + result: { + sessionId, + modes: modeState(), + }, + }); + return; + } + + if (method === "session/load" && id !== undefined) { + const requestedSessionId = msg.params?.sessionId ?? sessionId; + sendSessionUpdate( + { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "replay" }, + }, + requestedSessionId, + ); + send({ + jsonrpc: "2.0", + id, + result: { + modes: modeState(), + }, + }); + return; + } + + if (method === "session/prompt" && id !== undefined) { + const requestedSessionId = msg.params?.sessionId ?? sessionId; + if (emitToolCalls) { + const toolCallId = "tool-call-1"; + const permissionRequestId = nextRequestId++; + sendSessionUpdate( + { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + command: ["cat", "server/package.json"], + }, + }, + requestedSessionId, + ); + sendSessionUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + }, + requestedSessionId, + ); + pendingPermissionRequests.set(permissionRequestId, { + promptRequestId: id, + sessionId: requestedSessionId, + toolCallId, + }); + send({ + jsonrpc: "2.0", + id: permissionRequestId, + method: "session/request_permission", + params: { + sessionId: requestedSessionId, + toolCall: { + toolCallId, + title: "`cat server/package.json`", + kind: "execute", + status: "pending", + content: [ + { + type: "content", + content: { + type: "text", + text: "Not in allowlist: cat server/package.json", + }, + }, + ], + }, + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "allow-always", name: "Allow always", kind: "allow_always" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }, + }); + return; + } + sendSessionUpdate( + { + sessionUpdate: "plan", + explanation: `Mock plan while in ${currentModeId}`, + entries: [ + { + content: "Inspect mock ACP state", + priority: "high", + status: "completed", + }, + { + content: "Implement the requested change", + priority: "high", + status: "in_progress", + }, + ], + }, + requestedSessionId, + ); + sendSessionUpdate( + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from mock" }, + }, + requestedSessionId, + ); + send({ + jsonrpc: "2.0", + id, + result: { stopReason: "end_turn" }, + }); + return; + } + + if ((method === "session/set_mode" || method === "session/mode/set") && id !== undefined) { + const nextModeId = + typeof msg.params?.modeId === "string" + ? msg.params.modeId + : typeof msg.params?.mode === "string" + ? msg.params.mode + : undefined; + if (typeof nextModeId === "string" && nextModeId.trim()) { + currentModeId = nextModeId.trim(); + sendSessionUpdate({ + sessionUpdate: "current_mode_update", + currentModeId, + }); + } + send({ jsonrpc: "2.0", id, result: null }); + return; + } + + if (method === "session/cancel" && id !== undefined) { + send({ jsonrpc: "2.0", id, result: null }); + return; + } + + if (id !== undefined) { + send({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Unhandled method: ${String(method)}` }, + }); + } +}); diff --git a/apps/server/scripts/cursor-acp-model-selection-probe.ts b/apps/server/scripts/cursor-acp-model-selection-probe.ts new file mode 100644 index 00000000000..efcc78affbc --- /dev/null +++ b/apps/server/scripts/cursor-acp-model-selection-probe.ts @@ -0,0 +1,135 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Layer } from "effect"; + +import { ThreadId } from "@t3tools/contracts"; +import { resolveCursorDispatchModel } from "@t3tools/shared/model"; + +import { ServerConfig } from "../src/config.ts"; +import { ServerSettingsService } from "../src/serverSettings.ts"; +import { CursorAdapter } from "../src/provider/Services/CursorAdapter.ts"; +import { makeCursorAdapterLive } from "../src/provider/Layers/CursorAdapter.ts"; + +const scriptDir = import.meta.dir; +const mockAgentPath = path.join(scriptDir, "acp-mock-agent.mjs"); + +function parseArgs(argv: string[]) { + const args = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token?.startsWith("--")) continue; + const key = token.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + args.set(key, true); + continue; + } + args.set(key, next); + index += 1; + } + return args; +} + +async function makeProbeWrapper(requestLogPath: string, argvLogPath: string) { + const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-script-")); + const wrapperPath = path.join(dir, "fake-agent.sh"); + const script = `#!/bin/sh +printf '%s\n' "$@" > ${JSON.stringify(argvLogPath)} +export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await writeFile(wrapperPath, script, "utf8"); + await chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function readJsonLines(filePath: string) { + const raw = await readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const cliArgs = parseArgs(process.argv.slice(2)); +const model = + typeof cliArgs.get("model") === "string" ? String(cliArgs.get("model")) : "composer-2"; +const fastMode = cliArgs.get("fast") === true; + +const layer = makeCursorAdapterLive().pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), +); + +const program = Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath)); + const threadId = ThreadId.makeUnsafe("cursor-acp-model-selection-probe"); + const cursorModelOptions = fastMode ? { fastMode: true as const } : undefined; + const dispatchedModel = resolveCursorDispatchModel(model, cursorModelOptions); + + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + provider: "cursor", + model, + ...(cursorModelOptions ? { options: cursorModelOptions } : {}), + }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "probe model selection", + attachments: [], + }); + yield* adapter.stopSession(threadId); + + const argv = (yield* Effect.promise(() => readFile(argvLogPath, "utf8"))) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + const promptParams = + promptRequest?.params && + typeof promptRequest.params === "object" && + !Array.isArray(promptRequest.params) + ? promptRequest.params + : null; + + return { + input: { + model, + fastMode, + }, + dispatchedModel, + spawnedArgv: argv, + acpMethods: requests + .map((entry) => entry.method) + .filter((method): method is string => typeof method === "string"), + promptParams, + promptCarriesModel: Boolean( + promptParams && Object.prototype.hasOwnProperty.call(promptParams, "model"), + ), + conclusion: + "Cursor model selection is decided before ACP initialize via CLI argv. The ACP session/prompt payload does not carry a model field.", + }; +}).pipe(Effect.provide(layer)); + +const result = await Effect.runPromise(program); +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index e9f2230f432..2efc46e28ee 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "claudeAgent"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 834ab9be9ed..d206d30f942 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -611,6 +611,51 @@ describe("ProviderCommandReactor", () => { }); }); + it("routes turns by explicit provider even when the model slug is shared", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-cursor-shared-slug"), + threadId: ThreadId.makeUnsafe("thread-shared-slug"), + projectId: asProjectId("project-1"), + title: "Shared slug thread", + modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-shared-slug"), + threadId: ThreadId.makeUnsafe("thread-shared-slug"), + message: { + messageId: asMessageId("user-message-shared-slug"), + role: "user", + text: "first", + attachments: [], + }, + modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, + }); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -728,6 +773,61 @@ describe("ProviderCommandReactor", () => { }); }); + it("restarts cursor sessions on model changes while preserving resumeCursor", async () => { + const harness = await createHarness({ + threadModelSelection: { provider: "cursor", model: "composer-2" }, + sessionModelSwitch: "unsupported", + }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-1"), + role: "user", + text: "first cursor turn", + attachments: [], + }, + modelSelection: { provider: "cursor", model: "composer-2" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-2"), + role: "user", + text: "second cursor turn", + attachments: [], + }, + modelSelection: { provider: "cursor", model: "composer-2-fast" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + modelSelection: { provider: "cursor", model: "composer-2-fast" }, + resumeCursor: { opaque: "resume-1" }, + }); + }); + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 7c522e57995..8cacbca933e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -291,7 +291,7 @@ const make = Effect.gen(function* () { const modelChanged = requestedModelSelection !== undefined && requestedModelSelection.model !== activeSession?.model; - const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; + const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported"; const previousModelSelection = threadModelSelections.get(threadId); const shouldRestartForModelSelectionChange = currentProvider === "claudeAgent" && @@ -307,10 +307,7 @@ const make = Effect.gen(function* () { return existingSessionThreadId; } - const resumeCursor = - providerChanged || shouldRestartForModelChange - ? undefined - : (activeSession?.resumeCursor ?? undefined); + const resumeCursor = providerChanged ? undefined : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, existingSessionThreadId, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 8d205bbe2f3..88473d7c929 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -692,6 +692,62 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("preserves completed tool metadata on projected tool activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-tool-completed-with-data"), + provider: "cursor", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-tool-completed"), + itemId: asItemId("item-tool-completed"), + payload: { + itemType: "dynamic_tool_call", + status: "completed", + title: "Read File", + detail: "Read File", + data: { + toolCallId: "tool-read-1", + kind: "read", + rawOutput: { + content: 'import * as Effect from "effect/Effect"\n', + }, + }, + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-tool-completed-with-data", + ), + ); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.id === "evt-tool-completed-with-data", + ); + const payload = + activity?.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : undefined; + const data = + payload?.data && typeof payload.data === "object" + ? (payload.data as Record) + : undefined; + const rawOutput = + data?.rawOutput && typeof data.rawOutput === "object" + ? (data.rawOutput as Record) + : undefined; + + expect(activity?.kind).toBe("tool.completed"); + expect(payload?.itemType).toBe("dynamic_tool_call"); + expect(payload?.detail).toBe("Read File"); + expect(data?.toolCallId).toBe("tool-read-1"); + expect(data?.kind).toBe("read"); + expect(rawOutput?.content).toBe('import * as Effect from "effect/Effect"\n'); + }); + it("projects completed plan items into first-class proposed plans", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index f9a662b84fe..c7c4daf2512 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -498,6 +498,7 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts new file mode 100644 index 00000000000..fc429ecaa02 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -0,0 +1,484 @@ +import * as path from "node:path"; +import * as os from "node:os"; +import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Deferred, Effect, Fiber, Layer, Stream } from "effect"; + +import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; +import { resolveCursorDispatchModel } from "@t3tools/shared/model"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; +import { makeCursorAdapterLive } from "./CursorAdapter.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); + +async function makeMockAgentWrapper(extraEnv?: Record) { + const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-mock-")); + const wrapperPath = path.join(dir, "fake-agent.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await writeFile(wrapperPath, script, "utf8"); + await chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function makeProbeWrapper( + requestLogPath: string, + argvLogPath: string, + extraEnv?: Record, +) { + const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-")); + const wrapperPath = path.join(dir, "fake-agent.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)} +printf '\n' >> ${JSON.stringify(argvLogPath)} +export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} +${envExports} +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await writeFile(wrapperPath, script, "utf8"); + await chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function readArgvLog(filePath: string) { + const raw = await readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.split("\t").filter((token) => token.length > 0)); +} + +async function readJsonLines(filePath: string) { + const raw = await readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const cursorAdapterTestLayer = it.layer( + makeCursorAdapterLive().pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), +); + +cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-mock-thread"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }); + + assert.equal(session.provider, "cursor"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const types = runtimeEvents.map((e) => e.type); + + for (const t of [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "turn.plan.updated", + "content.delta", + "turn.completed", + ] as const) { + assert.include(types, t); + } + + const delta = runtimeEvents.find((e) => e.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + assert.isDefined(planUpdate); + if (planUpdate?.type === "turn.plan.updated") { + assert.deepStrictEqual(planUpdate.payload, { + explanation: "Mock plan while in code", + plan: [ + { step: "Inspect mock ACP state", status: "completed" }, + { step: "Implement the requested change", status: "inProgress" }, + ], + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects startSession when provider mismatches", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const result = yield* adapter + .startSession({ + threadId: ThreadId.makeUnsafe("bad-provider"), + provider: "codex", + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + }), + ); + + it.effect("selects the Cursor model via CLI argv instead of ACP request payloads", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-model-probe"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const dispatchedModel = resolveCursorDispatchModel("composer-2", { fastMode: true }); + const session = yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, + }); + + assert.equal(session.model, "composer-2"); + + yield* adapter.sendTurn({ + threadId, + input: "probe model selection", + attachments: [], + }); + yield* adapter.stopSession(threadId); + + const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.deepStrictEqual(argvRuns, [["--model", dispatchedModel, "acp"]]); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const methods = requests + .map((entry) => entry.method) + .filter((method): method is string => typeof method === "string"); + assert.includeMembers(methods, [ + "initialize", + "authenticate", + "session/new", + "session/set_mode", + "session/prompt", + ]); + + for (const request of requests) { + const params = request.params; + if (params && typeof params === "object" && !Array.isArray(params)) { + assert.isFalse(Object.prototype.hasOwnProperty.call(params, "model")); + } + } + + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + assert.isDefined(promptRequest); + assert.deepStrictEqual( + Object.keys((promptRequest?.params as Record) ?? {}).toSorted(), + ["prompt", "sessionId"], + ); + + const modeRequest = requests.find((entry) => entry.method === "session/set_mode"); + assert.isDefined(modeRequest); + assert.deepStrictEqual(modeRequest?.params, { + sessionId: "mock-session-1", + modeId: "code", + }); + }), + ); + + it.effect("maps app plan mode onto the ACP plan session mode", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-plan-mode-probe"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "composer-2" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "plan this change", + attachments: [], + interactionMode: "plan", + }); + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const modeRequest = requests.find((entry) => entry.method === "session/set_mode"); + assert.isDefined(modeRequest); + assert.deepStrictEqual(modeRequest?.params, { + sessionId: "mock-session-1", + modeId: "architect", + }); + }), + ); + + it.effect("streams ACP tool calls and approvals on the active turn in real time", () => + Effect.gen(function* () { + const previousEmitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS; + process.env.T3_ACP_EMIT_TOOL_CALLS = "1"; + + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-tool-call-probe"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "request.opened" && event.requestId) { + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(String(event.requestId)), + "accept", + ); + } + if ( + event.type === "turn.completed" || + event.type === "item.completed" || + event.type === "content.delta" + ) { + settledEventTypes.add(event.type); + if (settledEventTypes.size === 3) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); + + const program = Effect.gen(function* () { + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run a tool call", + attachments: [], + }); + yield* Deferred.await(settledEventsReady); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + assert.includeMembers( + threadEvents.map((event) => event.type), + [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "request.opened", + "request.resolved", + "item.updated", + "item.completed", + "content.delta", + "turn.completed", + ], + ); + + const turnEvents = threadEvents.filter( + (event) => String(event.turnId) === String(turn.turnId), + ); + const toolUpdates = turnEvents.filter((event) => event.type === "item.updated"); + assert.lengthOf(toolUpdates, 2); + for (const toolUpdate of toolUpdates) { + if (toolUpdate.type !== "item.updated") { + continue; + } + assert.equal(toolUpdate.payload.itemType, "command_execution"); + assert.equal(toolUpdate.payload.status, "inProgress"); + assert.equal(toolUpdate.payload.detail, "cat server/package.json"); + assert.equal(String(toolUpdate.itemId), "tool-call-1"); + } + + const requestOpened = turnEvents.find((event) => event.type === "request.opened"); + assert.isDefined(requestOpened); + if (requestOpened?.type === "request.opened") { + assert.equal(String(requestOpened.turnId), String(turn.turnId)); + assert.equal(requestOpened.payload.requestType, "exec_command_approval"); + assert.equal(requestOpened.payload.detail, "cat server/package.json"); + } + + const requestResolved = turnEvents.find((event) => event.type === "request.resolved"); + assert.isDefined(requestResolved); + if (requestResolved?.type === "request.resolved") { + assert.equal(String(requestResolved.turnId), String(turn.turnId)); + assert.equal(requestResolved.payload.requestType, "exec_command_approval"); + assert.equal(requestResolved.payload.decision, "accept"); + } + + const toolCompleted = turnEvents.find((event) => event.type === "item.completed"); + assert.isDefined(toolCompleted); + if (toolCompleted?.type === "item.completed") { + assert.equal(String(toolCompleted.turnId), String(turn.turnId)); + assert.equal(toolCompleted.payload.itemType, "command_execution"); + assert.equal(toolCompleted.payload.status, "completed"); + assert.equal(toolCompleted.payload.detail, "cat server/package.json"); + assert.equal(String(toolCompleted.itemId), "tool-call-1"); + } + + const contentDelta = turnEvents.find((event) => event.type === "content.delta"); + assert.isDefined(contentDelta); + if (contentDelta?.type === "content.delta") { + assert.equal(String(contentDelta.turnId), String(turn.turnId)); + assert.equal(contentDelta.payload.delta, "hello from mock"); + } + }); + + yield* program.pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousEmitToolCalls === undefined) { + delete process.env.T3_ACP_EMIT_TOOL_CALLS; + } else { + process.env.T3_ACP_EMIT_TOOL_CALLS = previousEmitToolCalls; + } + }), + ), + ); + }).pipe( + Effect.provide( + makeCursorAdapterLive().pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + it.effect("restarts ACP with session/load when the Cursor model changes mid-thread", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-model-restart"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "composer-2" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn", + attachments: [], + }); + + yield* adapter.sendTurn({ + threadId, + input: "second turn after switching model", + attachments: [], + modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, + }); + + const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.deepStrictEqual(argvRuns, [ + ["--model", "composer-2", "acp"], + ["--model", "composer-2-fast", "acp"], + ]); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const loadRequests = requests.filter((entry) => entry.method === "session/load"); + assert.lengthOf(loadRequests, 1); + assert.deepStrictEqual(loadRequests[0]?.params, { + sessionId: "mock-session-1", + cwd: process.cwd(), + mcpServers: [], + }); + + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts new file mode 100644 index 00000000000..121a46000e3 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -0,0 +1,1589 @@ +/** + * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP JSON-RPC. + * + * @module CursorAdapterLive + */ +import * as nodePath from "node:path"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; + +import { + ApprovalRequestId, + EventId, + type ProviderInteractionMode, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + type ToolLifecycleItemType, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { resolveCursorDispatchModel } from "@t3tools/shared/model"; +import { + Cause, + DateTime, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Queue, + Random, + Stream, +} from "effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { + attachAcpJsonRpcConnection, + disposeAcpChild, + spawnAcpChildProcess, + type AcpJsonRpcConnection, +} from "../acp/AcpJsonRpcConnection.ts"; +import type { AcpInboundMessage } from "../acp/AcpTypes.ts"; +import { AcpProcessExitedError, AcpRpcError, type AcpError } from "../acp/AcpErrors.ts"; +import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "cursor" as const; + +const CURSOR_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; +const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; +const ACP_APPROVAL_MODE_ALIASES = ["ask"]; + +export interface CursorAdapterLiveOptions { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface CursorSpawnOptions { + readonly binaryPath?: string | undefined; + readonly args?: ReadonlyArray | undefined; + readonly apiEndpoint?: string | undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseCursorResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function buildCursorSpawnInput(cwd: string, opts?: CursorSpawnOptions, model?: string | undefined) { + const command = opts?.binaryPath?.trim() || "agent"; + const hasCustomArgs = opts?.args && opts.args.length > 0; + const args = [ + ...(opts?.apiEndpoint ? (["-e", opts.apiEndpoint] as const) : []), + ...(model && !hasCustomArgs ? (["--model", model] as const) : []), + ...(hasCustomArgs ? opts.args : (["acp"] as const)), + ]; + return { command, args, cwd } as const; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function mapAcpToAdapterError( + threadId: ThreadId, + method: string, + error: AcpError, +): ProviderAdapterError { + if (error instanceof AcpProcessExitedError) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + cause: error, + }); + } + if (error instanceof AcpRpcError) { + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: error.message, + cause: error, + }); + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(error, `${method} failed`), + cause: error, + }); +} + +function acpPermissionOutcome(decision: ProviderApprovalDecision): string { + switch (decision) { + case "acceptForSession": + return "allow-always"; + case "accept": + return "allow-once"; + case "decline": + case "cancel": + default: + return "reject-once"; + } +} + +interface AcpSessionMode { + readonly id: string; + readonly name: string; + readonly description?: string; +} + +interface AcpSessionModeState { + readonly currentModeId: string; + readonly availableModes: ReadonlyArray; +} + +interface AcpToolCallState { + readonly toolCallId: string; + readonly itemType: ToolLifecycleItemType; + readonly title?: string; + readonly status?: "pending" | "inProgress" | "completed" | "failed"; + readonly command?: string; + readonly detail?: string; + readonly data: Record; +} + +function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" { + switch (raw) { + case "completed": + return "completed"; + case "in_progress": + case "inProgress": + return "inProgress"; + default: + return "pending"; + } +} + +function normalizeToolCallStatus( + raw: unknown, + fallback?: "pending" | "inProgress" | "completed" | "failed", +): "pending" | "inProgress" | "completed" | "failed" | undefined { + switch (raw) { + case "pending": + return "pending"; + case "in_progress": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return fallback; + } +} + +function runtimeItemStatusFromToolCallStatus( + status: "pending" | "inProgress" | "completed" | "failed" | undefined, +): "inProgress" | "completed" | "failed" | undefined { + switch (status) { + case "pending": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return undefined; + } +} + +function normalizeCommandValue(value: unknown): string | undefined { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (!Array.isArray(value)) { + return undefined; + } + const parts = value + .map((entry) => (typeof entry === "string" && entry.trim().length > 0 ? entry.trim() : null)) + .filter((entry): entry is string => entry !== null); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function extractCommandFromTitle(title: string | undefined): string | undefined { + if (!title) { + return undefined; + } + const match = /`([^`]+)`/.exec(title); + return match?.[1]?.trim() || undefined; +} + +function extractToolCallCommand(rawInput: unknown, title: string | undefined): string | undefined { + if (isRecord(rawInput)) { + const directCommand = normalizeCommandValue(rawInput.command); + if (directCommand) { + return directCommand; + } + const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : ""; + const args = normalizeCommandValue(rawInput.args); + if (executable && args) { + return `${executable} ${args}`; + } + if (executable) { + return executable; + } + } + return extractCommandFromTitle(title); +} + +function extractTextContentFromToolCallContent(content: unknown): string | undefined { + if (!Array.isArray(content)) { + return undefined; + } + const chunks = content + .map((entry) => { + if (!isRecord(entry)) { + return undefined; + } + if (entry.type !== "content") { + return undefined; + } + const nestedContent = entry.content; + if (!isRecord(nestedContent) || nestedContent.type !== "text") { + return undefined; + } + return typeof nestedContent.text === "string" && nestedContent.text.trim().length > 0 + ? nestedContent.text.trim() + : undefined; + }) + .filter((entry): entry is string => entry !== undefined); + return chunks.length > 0 ? chunks.join("\n") : undefined; +} + +function toolLifecycleItemTypeFromKind(kind: unknown): ToolLifecycleItemType { + switch (kind) { + case "execute": + return "command_execution"; + case "edit": + case "delete": + case "move": + return "file_change"; + case "search": + case "fetch": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + +function requestTypeFromToolKind( + kind: unknown, +): "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" { + switch (kind) { + case "execute": + return "exec_command_approval"; + case "read": + return "file_read_approval"; + case "edit": + case "delete": + case "move": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function parseToolCallState( + raw: unknown, + options?: { + readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; + }, +): AcpToolCallState | undefined { + if (!isRecord(raw)) { + return undefined; + } + const toolCallId = typeof raw.toolCallId === "string" ? raw.toolCallId.trim() : ""; + if (!toolCallId) { + return undefined; + } + const title = + typeof raw.title === "string" && raw.title.trim().length > 0 ? raw.title.trim() : undefined; + const command = extractToolCallCommand(raw.rawInput, title); + const textContent = extractTextContentFromToolCallContent(raw.content); + const normalizedTitle = + title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" + ? title + : undefined; + const detail = command ?? normalizedTitle ?? textContent; + const data: Record = { toolCallId }; + if (typeof raw.kind === "string" && raw.kind.trim().length > 0) { + data.kind = raw.kind.trim(); + } + if (command) { + data.command = command; + } + if (raw.rawInput !== undefined) { + data.rawInput = raw.rawInput; + } + if (raw.rawOutput !== undefined) { + data.rawOutput = raw.rawOutput; + } + if (raw.content !== undefined) { + data.content = raw.content; + } + if (raw.locations !== undefined) { + data.locations = raw.locations; + } + const status = normalizeToolCallStatus(raw.status, options?.fallbackStatus); + return { + toolCallId, + itemType: toolLifecycleItemTypeFromKind(raw.kind), + ...(title ? { title } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(detail ? { detail } : {}), + data, + } satisfies AcpToolCallState; +} + +function mergeToolCallState( + previous: AcpToolCallState | undefined, + next: AcpToolCallState, +): AcpToolCallState { + const nextKind = typeof next.data.kind === "string" ? next.data.kind : undefined; + const title = next.title ?? previous?.title; + const status = next.status ?? previous?.status; + const command = next.command ?? previous?.command; + const detail = next.detail ?? previous?.detail; + return { + toolCallId: next.toolCallId, + itemType: nextKind !== undefined ? next.itemType : (previous?.itemType ?? next.itemType), + ...(title ? { title } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(detail ? { detail } : {}), + data: { + ...previous?.data, + ...next.data, + }, + } satisfies AcpToolCallState; +} + +function parsePermissionRequest(params: unknown): { + requestType: "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown"; + detail?: string; + toolCall?: AcpToolCallState; +} { + if (!isRecord(params)) { + return { requestType: "unknown" }; + } + const toolCall = parseToolCallState(params.toolCall, { fallbackStatus: "pending" }); + const requestType = requestTypeFromToolKind( + isRecord(params.toolCall) ? params.toolCall.kind : undefined, + ); + const detail = + toolCall?.command ?? + toolCall?.title ?? + toolCall?.detail ?? + (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); + return { + requestType, + ...(detail ? { detail } : {}), + ...(toolCall ? { toolCall } : {}), + }; +} + +function parseSessionModeState(raw: unknown): AcpSessionModeState | undefined { + if (!isRecord(raw)) return undefined; + const modes = isRecord(raw.modes) ? raw.modes : raw; + const currentModeId = + typeof modes.currentModeId === "string" && modes.currentModeId.trim().length > 0 + ? modes.currentModeId.trim() + : undefined; + if (!currentModeId) { + return undefined; + } + const rawModes = modes.availableModes; + if (!Array.isArray(rawModes)) { + return undefined; + } + const availableModes = rawModes + .map((mode) => { + if (!isRecord(mode)) return undefined; + const id = typeof mode.id === "string" ? mode.id.trim() : ""; + const name = typeof mode.name === "string" ? mode.name.trim() : ""; + if (!id || !name) { + return undefined; + } + const description = + typeof mode.description === "string" && mode.description.trim().length > 0 + ? mode.description.trim() + : undefined; + return description !== undefined + ? ({ id, name, description } satisfies AcpSessionMode) + : ({ id, name } satisfies AcpSessionMode); + }) + .filter((mode): mode is AcpSessionMode => mode !== undefined); + if (availableModes.length === 0) { + return undefined; + } + return { + currentModeId, + availableModes, + }; +} + +function normalizeModeSearchText(mode: AcpSessionMode): string { + return [mode.id, mode.name, mode.description] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function findModeByAliases( + modes: ReadonlyArray, + aliases: ReadonlyArray, +): AcpSessionMode | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find((mode) => { + const id = mode.id.toLowerCase(); + const name = mode.name.toLowerCase(); + return id === alias || name === alias; + }); + if (exact) { + return exact; + } + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); + if (partial) { + return partial; + } + } + return undefined; +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; +} + +function resolveRequestedModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; + } + + if (input.runtimeMode === "approval-required") { + return ( + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); + } + + return ( + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +function updateSessionModeState( + modeState: AcpSessionModeState | undefined, + nextModeId: string, +): AcpSessionModeState | undefined { + if (!modeState) { + return undefined; + } + const normalizedModeId = nextModeId.trim(); + if (!normalizedModeId) { + return modeState; + } + return modeState.availableModes.some((mode) => mode.id === normalizedModeId) + ? { + ...modeState, + currentModeId: normalizedModeId, + } + : modeState; +} + +function isMethodNotFoundRpcError(error: AcpError): boolean { + return ( + error instanceof AcpRpcError && + (error.code === -32601 || error.message.toLowerCase().includes("method not found")) + ); +} + +function parseSessionUpdate(params: unknown): { + sessionUpdate?: string; + text?: string; + modeId?: string; + plan?: { + explanation?: string | null; + plan: ReadonlyArray<{ step: string; status: "pending" | "inProgress" | "completed" }>; + }; + toolCall?: AcpToolCallState; +} { + if (!isRecord(params)) return {}; + const upd = params.update; + if (!isRecord(upd)) return {}; + const su = typeof upd.sessionUpdate === "string" ? upd.sessionUpdate : undefined; + const modeId = + typeof upd.modeId === "string" + ? upd.modeId + : typeof upd.currentModeId === "string" + ? upd.currentModeId + : undefined; + if (su === "plan") { + const entries = Array.isArray(upd.entries) ? upd.entries : undefined; + const plan = + entries + ?.map((entry, index) => { + if (!isRecord(entry)) { + return undefined; + } + const step = + typeof entry.content === "string" && entry.content.trim().length > 0 + ? entry.content.trim() + : `Step ${index + 1}`; + return { + step, + status: normalizePlanStepStatus(entry.status), + } as const; + }) + .filter( + ( + entry, + ): entry is { + step: string; + status: "pending" | "inProgress" | "completed"; + } => entry !== undefined, + ) ?? []; + if (plan.length > 0) { + const explanation = + typeof upd.explanation === "string" + ? upd.explanation + : upd.explanation === null + ? null + : undefined; + return { + sessionUpdate: su, + ...(modeId !== undefined ? { modeId } : {}), + plan: { + ...(explanation !== undefined ? { explanation } : {}), + plan, + }, + }; + } + } + if (su === "tool_call" || su === "tool_call_update") { + const toolCall = parseToolCallState( + upd, + su === "tool_call" ? { fallbackStatus: "pending" } : undefined, + ); + if (toolCall) { + return { + sessionUpdate: su, + ...(modeId !== undefined ? { modeId } : {}), + toolCall, + }; + } + } + const content = upd.content; + if (!isRecord(content)) { + return { + ...(su !== undefined ? { sessionUpdate: su } : {}), + ...(modeId !== undefined ? { modeId } : {}), + }; + } + const text = typeof content.text === "string" ? content.text : undefined; + if (su !== undefined && text !== undefined) { + return { + sessionUpdate: su, + text, + ...(modeId !== undefined ? { modeId } : {}), + }; + } + if (su !== undefined) { + return { + sessionUpdate: su, + ...(modeId !== undefined ? { modeId } : {}), + }; + } + if (text !== undefined) { + return { + text, + ...(modeId !== undefined ? { modeId } : {}), + }; + } + return {}; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly requestType: + | "exec_command_approval" + | "file_read_approval" + | "file_change_approval" + | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface CursorSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly spawnOptions?: CursorSpawnOptions | undefined; + readonly child: ChildProcessWithoutNullStreams; + readonly conn: AcpJsonRpcConnection; + acpSessionId: string; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + readonly toolCalls: Map; + modeState: AcpSessionModeState | undefined; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + stopped: boolean; +} + +function makeCursorAdapter(options?: CursorAdapterLiveOptions) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* Effect.service(ServerConfig); + const serverSettingsService = yield* ServerSettingsService; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const emitPlanUpdate = ( + ctx: CursorSessionContext, + payload: { + explanation?: string | null; + plan: ReadonlyArray<{ step: string; status: "pending" | "inProgress" | "completed" }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | "acp.cursor.extension", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${JSON.stringify(payload)}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.plan.updated", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + raw: { + source, + method, + payload: rawPayload, + }, + }); + }); + + const emitToolCallEvent = ( + ctx: CursorSessionContext, + toolCall: AcpToolCallState, + rawPayload: unknown, + ) => + Effect.gen(function* () { + const runtimeStatus = runtimeItemStatusFromToolCallStatus(toolCall.status); + const payload = { + itemType: toolCall.itemType, + ...(runtimeStatus ? { status: runtimeStatus } : {}), + ...(toolCall.title ? { title: toolCall.title } : {}), + ...(toolCall.detail ? { detail: toolCall.detail } : {}), + ...(Object.keys(toolCall.data).length > 0 ? { data: toolCall.data } : {}), + }; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: + toolCall.status === "completed" || toolCall.status === "failed" + ? "item.completed" + : "item.updated", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: RuntimeItemId.makeUnsafe(toolCall.toolCallId), + payload, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: rawPayload, + }, + }); + if (toolCall.status === "completed" || toolCall.status === "failed") { + ctx.toolCalls.delete(toolCall.toolCallId); + } + }); + + const setSessionMode = (ctx: CursorSessionContext, modeId: string | undefined) => + Effect.gen(function* () { + const normalizedModeId = modeId?.trim(); + if (!normalizedModeId) { + return; + } + if (ctx.modeState?.currentModeId === normalizedModeId) { + return; + } + const setModeParams = { sessionId: ctx.acpSessionId, modeId: normalizedModeId }; + const setModeExit = yield* Effect.exit(ctx.conn.request("session/set_mode", setModeParams)); + if (Exit.isSuccess(setModeExit)) { + ctx.modeState = updateSessionModeState(ctx.modeState, normalizedModeId); + return; + } + const error = Cause.squash(setModeExit.cause) as AcpError; + if (!isMethodNotFoundRpcError(error)) { + return yield* mapAcpToAdapterError(ctx.threadId, "session/set_mode", error); + } + yield* ctx.conn + .request("session/mode/set", { + sessionId: ctx.acpSessionId, + mode: normalizedModeId, + }) + .pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(ctx.threadId, "session/mode/set", cause), + ), + ); + ctx.modeState = updateSessionModeState(ctx.modeState, normalizedModeId); + }); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc" | "acp.cursor.extension", + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = new Date().toISOString(); + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: crypto.randomUUID(), + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: CursorSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + disposeAcpChild(ctx.child); + sessions.delete(ctx.threadId); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: CursorAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + const cwd = nodePath.resolve(input.cwd.trim()); + const cursorSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.cursor), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + const cursorOpts: CursorSpawnOptions = { + binaryPath: cursorSettings.binaryPath, + apiEndpoint: cursorSettings.apiEndpoint || undefined, + }; + const cursorModelSelection = + input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; + const initialModel = resolveCursorDispatchModel( + cursorModelSelection?.model, + cursorModelSelection?.options, + ); + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + const spawnInput = buildCursorSpawnInput(cwd, cursorOpts, initialModel); + const child = yield* spawnAcpChildProcess(spawnInput).pipe( + Effect.mapError( + (e) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: e.message, + cause: e, + }), + ), + ); + + const conn = yield* attachAcpJsonRpcConnection(child).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to attach ACP JSON-RPC to child process.", + cause, + }), + ), + ); + + const ctx: CursorSessionContext = { + threadId: input.threadId, + session: {} as ProviderSession, + spawnOptions: cursorOpts, + child, + conn, + acpSessionId: "", + notificationFiber: undefined, + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + turns: [], + toolCalls: new Map(), + modeState: undefined, + lastPlanFingerprint: undefined, + activeTurnId: undefined, + stopped: false, + }; + + const registerHandlers = (ctx: CursorSessionContext) => + Effect.gen(function* () { + yield* conn.registerHandler("session/request_permission", (params, _acpId) => + Effect.gen(function* () { + yield* logNative(ctx.threadId, "session/request_permission", params, "acp.jsonrpc"); + const permissionRequest = parsePermissionRequest(params); + if (permissionRequest.toolCall) { + const previousToolCall = ctx.toolCalls.get(permissionRequest.toolCall.toolCallId); + ctx.toolCalls.set( + permissionRequest.toolCall.toolCallId, + mergeToolCallState(previousToolCall, permissionRequest.toolCall), + ); + } + const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); + const decision = yield* Deferred.make(); + ctx.pendingApprovals.set(requestId, { + decision, + requestType: permissionRequest.requestType, + }); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + requestId: runtimeRequestId, + payload: { + requestType: permissionRequest.requestType, + ...(permissionRequest.detail + ? { detail: permissionRequest.detail } + : { detail: JSON.stringify(params).slice(0, 2000) }), + args: params, + }, + raw: { + source: "acp.jsonrpc", + method: "session/request_permission", + payload: params, + }, + }); + const d = yield* Deferred.await(decision); + ctx.pendingApprovals.delete(requestId); + const stamp2 = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + ...stamp2, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + requestId: runtimeRequestId, + payload: { + requestType: permissionRequest.requestType, + decision: d, + }, + }); + return { + outcome: { outcome: "selected", optionId: acpPermissionOutcome(d) }, + }; + }), + ); + + yield* conn.registerHandler("cursor/ask_question", (params, _acpId) => + Effect.gen(function* () { + yield* logNative( + ctx.threadId, + "cursor/ask_question", + params, + "acp.cursor.extension", + ); + const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); + const answers = yield* Deferred.make(); + ctx.pendingUserInputs.set(requestId, { answers }); + const questions = extractAskQuestions(params); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + requestId: runtimeRequestId, + payload: { questions }, + raw: { + source: "acp.cursor.extension", + method: "cursor/ask_question", + payload: params, + }, + }); + const a = yield* Deferred.await(answers); + ctx.pendingUserInputs.delete(requestId); + const stamp2 = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...stamp2, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: a }, + }); + return { answers: a }; + }), + ); + + yield* conn.registerHandler("cursor/create_plan", (params, _acpId) => + Effect.gen(function* () { + yield* logNative( + ctx.threadId, + "cursor/create_plan", + params, + "acp.cursor.extension", + ); + const planMarkdown = extractPlanMarkdown(params); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { planMarkdown }, + raw: { + source: "acp.cursor.extension", + method: "cursor/create_plan", + payload: params, + }, + }); + return { accepted: true }; + }), + ); + + yield* conn.registerHandler("cursor/update_todos", (params, _acpId) => + Effect.gen(function* () { + yield* logNative( + ctx.threadId, + "cursor/update_todos", + params, + "acp.cursor.extension", + ); + const plan = extractTodosAsPlan(params); + yield* emitPlanUpdate( + ctx, + plan, + params, + "acp.cursor.extension", + "cursor/update_todos", + ); + return {}; + }), + ); + }); + + yield* registerHandlers(ctx); + + const init = yield* conn + .request("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "t3-code", version: "0.0.0" }, + }) + .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "initialize", e))); + + yield* conn + .request("authenticate", { methodId: "cursor_login" }) + .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "authenticate", e))); + + const resume = parseCursorResume(input.resumeCursor); + let acpSessionId: string; + let sessionSetupResult: unknown = undefined; + if (resume) { + const loadExit = yield* Effect.exit( + conn.request("session/load", { + sessionId: resume.sessionId, + cwd, + mcpServers: [], + }), + ); + if (Exit.isSuccess(loadExit)) { + acpSessionId = resume.sessionId; + sessionSetupResult = loadExit.value; + } else { + const created = yield* conn + .request("session/new", { cwd, mcpServers: [] }) + .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "session/new", e))); + const cr = created as { sessionId?: string }; + if (typeof cr.sessionId !== "string") { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/new", + detail: "session/new missing sessionId", + cause: created, + }); + } + acpSessionId = cr.sessionId; + sessionSetupResult = created; + } + } else { + const created = yield* conn + .request("session/new", { cwd, mcpServers: [] }) + .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "session/new", e))); + const cr = created as { sessionId?: string }; + if (typeof cr.sessionId !== "string") { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/new", + detail: "session/new missing sessionId", + cause: created, + }); + } + acpSessionId = cr.sessionId; + sessionSetupResult = created; + } + + const now = yield* nowIso; + const resumeCursor = { + schemaVersion: CURSOR_RESUME_VERSION, + sessionId: acpSessionId, + }; + + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: cursorModelSelection?.model, + threadId: input.threadId, + resumeCursor, + createdAt: now, + updatedAt: now, + }; + + ctx.session = session; + ctx.acpSessionId = acpSessionId; + ctx.modeState = parseSessionModeState(sessionSetupResult); + + const handleNotification = (msg: AcpInboundMessage) => + Effect.gen(function* () { + if (msg._tag !== "notification" || msg.method !== "session/update") return; + yield* logNative(ctx.threadId, "session/update", msg.params, "acp.jsonrpc"); + const p = parseSessionUpdate(msg.params); + if (p.modeId) { + ctx.modeState = updateSessionModeState(ctx.modeState, p.modeId); + } + if (p.sessionUpdate === "plan" && p.plan) { + yield* emitPlanUpdate(ctx, p.plan, msg.params, "acp.jsonrpc", "session/update"); + } + if ( + (p.sessionUpdate === "tool_call" || p.sessionUpdate === "tool_call_update") && + p.toolCall + ) { + const previousToolCall = ctx.toolCalls.get(p.toolCall.toolCallId); + const mergedToolCall = mergeToolCallState(previousToolCall, p.toolCall); + ctx.toolCalls.set(mergedToolCall.toolCallId, mergedToolCall); + yield* emitToolCallEvent(ctx, mergedToolCall, msg.params); + } + if ( + (p.sessionUpdate === "agent_message_chunk" || + p.sessionUpdate === "assistant_message_chunk") && + p.text + ) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + streamKind: "assistant_text", + delta: p.text, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: msg.params, + }, + }); + } + }); + + const nf = yield* Stream.runDrain( + Stream.mapEffect(conn.notifications, handleNotification), + ).pipe(Effect.forkChild); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: init }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Cursor ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: acpSessionId }, + }); + + return session; + }); + + const sendTurn: CursorAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + let ctx = yield* requireSession(input.threadId); + const turnId = TurnId.makeUnsafe(crypto.randomUUID()); + const turnModelSelection = + input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; + const model = resolveCursorDispatchModel( + turnModelSelection?.model ?? ctx.session.model, + turnModelSelection?.options, + ); + const activeModel = resolveCursorDispatchModel(ctx.session.model, undefined); + if (model !== activeModel) { + yield* stopSessionInternal(ctx); + yield* startSession({ + threadId: input.threadId, + provider: PROVIDER, + cwd: ctx.session.cwd, + runtimeMode: ctx.session.runtimeMode, + modelSelection: turnModelSelection ?? { provider: PROVIDER, model }, + ...(ctx.session.resumeCursor !== undefined + ? { resumeCursor: ctx.session.resumeCursor } + : {}), + }); + ctx = yield* requireSession(input.threadId); + } + ctx.activeTurnId = turnId; + ctx.lastPlanFingerprint = undefined; + ctx.toolCalls.clear(); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: ctx.session.runtimeMode, + modeState: ctx.modeState, + }); + yield* Effect.ignore(setSessionMode(ctx, requestedModeId)); + + const stampStart = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + ...stampStart, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model }, + }); + + const promptParts: Array> = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: toMessage(cause, "Failed to read attachment."), + cause, + }), + ), + ); + promptParts.push({ + type: "image", + image: { + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.conn + .request("session/prompt", { + sessionId: ctx.acpSessionId, + prompt: promptParts, + }) + .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "session/prompt", e))); + + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model, + }; + + const pr = result as { stopReason?: string | null }; + const stampEnd = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...stampEnd, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: "completed", + stopReason: pr.stopReason ?? null, + }, + }); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }); + + const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* Effect.ignore(ctx.conn.request("session/cancel", { sessionId: ctx.acpSessionId })); + }); + + const respondToRequest: CursorAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "cursor/ask_question", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: CursorAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { + threadId, + turns: ctx.turns, + }; + }); + + const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: CursorAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }); + + const listSessions: CursorAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: CursorAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: CursorAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.tap(() => Queue.shutdown(runtimeEventQueue)), + ), + ); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "unsupported" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies CursorAdapterShape; + }); +} + +function extractAskQuestions(params: unknown): ReadonlyArray { + if (!isRecord(params)) return []; + const qs = params.questions ?? params.question; + if (!Array.isArray(qs)) return []; + const out: UserInputQuestion[] = []; + for (const q of qs) { + if (!isRecord(q)) continue; + const id = typeof q.id === "string" ? q.id : "question"; + const header = typeof q.header === "string" ? q.header : "Question"; + const question = typeof q.question === "string" ? q.question : ""; + const rawOpts = q.options; + const options: Array<{ label: string; description: string }> = []; + if (Array.isArray(rawOpts)) { + for (const o of rawOpts) { + if (!isRecord(o)) continue; + const label = typeof o.label === "string" ? o.label : "Option"; + const description = typeof o.description === "string" ? o.description : label; + options.push({ label, description }); + } + } + if (options.length === 0) { + options.push({ label: "OK", description: "Continue" }); + } + out.push({ id, header, question, options }); + } + return out.length > 0 + ? out + : [{ id: "q1", header: "Input", question: "?", options: [{ label: "OK", description: "OK" }] }]; +} + +function extractPlanMarkdown(params: unknown): string { + if (!isRecord(params)) return ""; + const pm = + typeof params.plan === "string" + ? params.plan + : typeof params.planMarkdown === "string" + ? params.planMarkdown + : typeof params.markdown === "string" + ? params.markdown + : ""; + return pm || "# Plan\n\n(Cursor did not supply plan text.)"; +} + +function extractTodosAsPlan(params: unknown): { + explanation?: string; + plan: ReadonlyArray<{ step: string; status: "pending" | "inProgress" | "completed" }>; +} { + if (!isRecord(params)) { + return { plan: [] }; + } + const todos = params.todos ?? params.items; + if (!Array.isArray(todos)) { + return { plan: [] }; + } + const plan = todos.map((t, i) => { + if (!isRecord(t)) { + return { step: `Step ${i + 1}`, status: "pending" as const }; + } + const step = + typeof t.content === "string" + ? t.content + : typeof t.title === "string" + ? t.title + : `Step ${i + 1}`; + const st = t.status; + const status = normalizePlanStepStatus(st); + return { step, status }; + }); + return { plan }; +} + +export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter()); + +export function makeCursorAdapterLive(opts?: CursorAdapterLiveOptions) { + return Layer.effect(CursorAdapter, makeCursorAdapter(opts)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0fea..d92293bfa1e 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -6,6 +6,7 @@ import { Effect, Layer, Stream } from "effect"; import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -45,6 +46,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; +const fakeCursorAdapter: CursorAdapterShape = { + provider: "cursor", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -52,6 +70,7 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + Layer.succeed(CursorAdapter, fakeCursorAdapter), ), ), NodeServices.layer, @@ -64,11 +83,13 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const claude = yield* registry.getByProvider("claudeAgent"); + const cursor = yield* registry.getByProvider("cursor"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); + assert.equal(cursor, fakeCursorAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "cursor"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 23ef8d1b9b9..f953ce5acfa 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -17,6 +17,7 @@ import { } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -27,7 +28,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter]; + : [yield* CodexAdapter, yield* ClaudeAdapter, yield* CursorAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 651a6116492..54c369dac77 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -233,14 +233,17 @@ const sleep = (ms: number) => function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter("claudeAgent"); + const cursor = makeFakeCodexAdapter("cursor"); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) : provider === "claudeAgent" ? Effect.succeed(claude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex", "claudeAgent"]), + : provider === "cursor" + ? Effect.succeed(cursor.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex", "claudeAgent", "cursor"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); @@ -267,6 +270,7 @@ function makeProviderServiceLayer() { return { codex, claude, + cursor, layer, }; } diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d6961..b6c51db8854 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -22,7 +22,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "cursor") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts new file mode 100644 index 00000000000..8b642389556 --- /dev/null +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -0,0 +1,12 @@ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface CursorAdapterShape extends ProviderAdapterShape { + readonly provider: "cursor"; +} + +export class CursorAdapter extends ServiceMap.Service()( + "t3/provider/Services/CursorAdapter", +) {} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 38a05f75748..91155764f1c 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -23,7 +23,7 @@ import type { import type { Effect } from "effect"; import type { Stream } from "effect"; -export type ProviderSessionModelSwitchMode = "in-session" | "restart-session" | "unsupported"; +export type ProviderSessionModelSwitchMode = "in-session" | "unsupported"; export interface ProviderAdapterCapabilities { /** diff --git a/apps/server/src/provider/acp/AcpErrors.ts b/apps/server/src/provider/acp/AcpErrors.ts new file mode 100644 index 00000000000..40b35ca3167 --- /dev/null +++ b/apps/server/src/provider/acp/AcpErrors.ts @@ -0,0 +1,24 @@ +import { Data } from "effect"; + +export class AcpSpawnError extends Data.TaggedError("AcpSpawnError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export class AcpParseError extends Data.TaggedError("AcpParseError")<{ + readonly line: string; + readonly cause?: unknown; +}> {} + +export class AcpRpcError extends Data.TaggedError("AcpRpcError")<{ + readonly code: number; + readonly message: string; + readonly data?: unknown; +}> {} + +export class AcpProcessExitedError extends Data.TaggedError("AcpProcessExitedError")<{ + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +}> {} + +export type AcpError = AcpSpawnError | AcpParseError | AcpRpcError | AcpProcessExitedError; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts new file mode 100644 index 00000000000..3ad8bbb7111 --- /dev/null +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -0,0 +1,51 @@ +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Stream } from "effect"; +import { describe, expect } from "vitest"; + +import { makeAcpJsonRpcConnection } from "./AcpJsonRpcConnection.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); + +describe("AcpJsonRpcConnection", () => { + it.effect("performs initialize → session/new → session/prompt against mock agent", () => + Effect.gen(function* () { + const conn = yield* makeAcpJsonRpcConnection({ + command: process.execPath, + args: [mockAgentPath], + }); + + const initResult = yield* conn.request("initialize", { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: "t3-test", version: "0.0.0" }, + }); + expect(initResult).toMatchObject({ protocolVersion: 1 }); + + yield* conn.request("authenticate", { methodId: "cursor_login" }); + + const newResult = yield* conn.request("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + expect(newResult).toEqual({ sessionId: "mock-session-1" }); + + const promptResult = yield* conn.request("session/prompt", { + sessionId: "mock-session-1", + prompt: [{ type: "text", text: "hi" }], + }); + expect(promptResult).toMatchObject({ stopReason: "end_turn" }); + + const notes = yield* Stream.runCollect(Stream.take(conn.notifications, 1)); + expect(notes.length).toBe(1); + expect(notes[0]?._tag).toBe("notification"); + if (notes[0]?._tag === "notification") { + expect(notes[0].method).toBe("session/update"); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.ts new file mode 100644 index 00000000000..cc44e46af96 --- /dev/null +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.ts @@ -0,0 +1,250 @@ +import { createInterface } from "node:readline"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +import { Cause, Deferred, Effect, Exit, Queue, Ref, Scope, Semaphore, Stream } from "effect"; + +import { + AcpParseError, + AcpProcessExitedError, + AcpRpcError, + AcpSpawnError, + type AcpError, +} from "./AcpErrors.ts"; +import { + decodeAcpInboundFromJsonLine, + type AcpInboundMessage, + type AcpServerRequestHandler, + type AcpSpawnInput, +} from "./AcpTypes.ts"; + +const JSON_RPC_VERSION = "2.0"; + +function parseInboundLine(line: string): Effect.Effect { + const trimmed = line.trim(); + if (!trimmed) { + return Effect.succeed(null); + } + const lineSnippet = trimmed.slice(0, 500); + return decodeAcpInboundFromJsonLine(trimmed).pipe( + Effect.mapError((cause) => new AcpParseError({ line: lineSnippet, cause })), + ); +} + +export interface AcpJsonRpcConnection { + readonly request: (method: string, params?: unknown) => Effect.Effect; + readonly notify: (method: string, params?: unknown) => Effect.Effect; + readonly registerHandler: ( + method: string, + handler: AcpServerRequestHandler, + ) => Effect.Effect; + readonly notifications: Stream.Stream; +} + +export function spawnAcpChildProcess( + input: AcpSpawnInput, +): Effect.Effect { + return Effect.try({ + try: () => { + const c = spawn(input.command, [...input.args], { + cwd: input.cwd, + env: { ...process.env, ...input.env }, + stdio: ["pipe", "pipe", "inherit"], + shell: process.platform === "win32", + }); + if (!c.stdin || !c.stdout) { + throw new Error("Child process missing stdio pipes."); + } + return c as unknown as ChildProcessWithoutNullStreams; + }, + catch: (cause) => + new AcpSpawnError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); +} + +export function disposeAcpChild(child: ChildProcessWithoutNullStreams) { + try { + child.stdin?.end(); + } catch { + /* ignore */ + } + try { + child.kill("SIGTERM"); + } catch { + /* ignore */ + } +} + +/** + * Attach JSON-RPC framing to an existing child process (caller owns spawn/kill). + */ +export const attachAcpJsonRpcConnection = ( + child: ChildProcessWithoutNullStreams, +): Effect.Effect => + Effect.gen(function* () { + const writeLock = yield* Semaphore.make(1); + const pending = yield* Ref.make( + new Map>(), + ); + const handlers = yield* Ref.make(new Map()); + const nextId = yield* Ref.make(1); + const notificationQueue = yield* Queue.unbounded(); + + const failAllPending = (error: AcpError) => + Ref.get(pending).pipe( + Effect.flatMap((map) => + Effect.forEach([...map.values()], (def) => Deferred.fail(def, error), { + discard: true, + }), + ), + Effect.tap(() => Ref.set(pending, new Map())), + ); + + const writeRawLine = (payload: Record) => + Effect.try({ + try: () => { + child.stdin.write(`${JSON.stringify(payload)}\n`); + }, + catch: (cause) => + new AcpSpawnError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + + const writeSerialized = (payload: Record) => + writeLock.withPermits(1)(writeRawLine(payload)); + + const sendRequest = (method: string, params?: unknown) => + Effect.gen(function* () { + const deferred = yield* Deferred.make(); + yield* writeLock.withPermits(1)( + Effect.gen(function* () { + const id = yield* Ref.get(nextId); + yield* Ref.set(nextId, id + 1); + yield* Ref.update(pending, (map) => new Map(map).set(id, deferred)); + yield* writeRawLine({ + jsonrpc: JSON_RPC_VERSION, + id, + method, + ...(params !== undefined ? { params } : {}), + }); + }), + ); + return yield* Deferred.await(deferred); + }); + + const sendNotify = (method: string, params?: unknown) => + writeSerialized({ + jsonrpc: JSON_RPC_VERSION, + method, + ...(params !== undefined ? { params } : {}), + }).pipe(Effect.asVoid); + + const respondResult = (id: number | string, result: unknown) => + writeSerialized({ jsonrpc: JSON_RPC_VERSION, id, result }); + + const respondError = (id: number | string, message: string, code = -32601) => + writeSerialized({ + jsonrpc: JSON_RPC_VERSION, + id, + error: { code, message }, + }); + + const handleOneLine = (line: string): Effect.Effect => + Effect.gen(function* () { + const parseExit = yield* parseInboundLine(line).pipe(Effect.exit); + if (Exit.isFailure(parseExit)) { + return; + } + if (parseExit.value === null) { + return; + } + const msg = parseExit.value; + + if (msg._tag === "response") { + const map = yield* Ref.get(pending); + const def = map.get(msg.id); + if (!def) return; + const next = new Map(map); + next.delete(msg.id); + yield* Ref.set(pending, next); + if (msg.error) { + yield* Deferred.fail( + def, + new AcpRpcError({ + code: msg.error.code, + message: msg.error.message, + ...(msg.error.data !== undefined ? { data: msg.error.data } : {}), + }), + ); + } else { + yield* Deferred.succeed(def, msg.result); + } + return; + } + + if (msg._tag === "notification") { + yield* Queue.offer(notificationQueue, msg); + return; + } + + const handlerMap = yield* Ref.get(handlers); + const handler = handlerMap.get(msg.method); + if (!handler) { + yield* respondError(msg.id, `Method not found: ${msg.method}`); + return; + } + + const exit = yield* Effect.exit(handler(msg.params, msg.id)); + if (Exit.isSuccess(exit)) { + yield* respondResult(msg.id, exit.value); + } else { + const left = Cause.squash(exit.cause); + yield* respondError(msg.id, left instanceof AcpRpcError ? left.message : String(left)); + } + }); + + yield* Effect.sync(() => { + child.once("close", (code: number | null, signal: NodeJS.Signals | null) => { + const err = new AcpProcessExitedError({ code, signal }); + void Effect.runPromise( + failAllPending(err).pipe(Effect.tap(() => Queue.shutdown(notificationQueue))), + ).catch(() => { + /* ignore shutdown races */ + }); + }); + }); + + const rl = createInterface({ input: child.stdout, crlfDelay: Infinity }); + yield* Effect.sync(() => { + rl.on("line", (ln: string) => { + void Effect.runPromise(handleOneLine(ln)).catch(() => { + /* parse/handler errors are non-fatal for the transport */ + }); + }); + }); + + const registerHandler = (method: string, handler: AcpServerRequestHandler) => + Ref.update(handlers, (map) => new Map(map).set(method, handler)); + + return { + request: sendRequest, + notify: sendNotify, + registerHandler, + notifications: Stream.fromQueue(notificationQueue), + } satisfies AcpJsonRpcConnection; + }); + +/** + * Spawns an ACP agent process and exposes NDJSON JSON-RPC over stdio. + * Run under `Effect.scoped` so the child is disposed when the scope ends. + */ +export const makeAcpJsonRpcConnection = ( + input: AcpSpawnInput, +): Effect.Effect => + Effect.acquireRelease(spawnAcpChildProcess(input), (child) => + Effect.sync(() => disposeAcpChild(child)), + ).pipe(Effect.flatMap(attachAcpJsonRpcConnection)); diff --git a/apps/server/src/provider/acp/AcpTypes.ts b/apps/server/src/provider/acp/AcpTypes.ts new file mode 100644 index 00000000000..cc593a4f796 --- /dev/null +++ b/apps/server/src/provider/acp/AcpTypes.ts @@ -0,0 +1,147 @@ +import { Effect, Option, Schema, SchemaIssue, SchemaTransformation } from "effect"; + +import type { AcpError } from "./AcpErrors.ts"; + +/** JSON-RPC 2.0 error object on the wire. */ +export const JsonRpcErrorPayload = Schema.Struct({ + code: Schema.Number, + message: Schema.String, + data: Schema.optional(Schema.Unknown), +}); + +/** Parsed JSON object from one NDJSON line before JSON-RPC classification. */ +export const JsonRpcInboundWire = Schema.Struct({ + jsonrpc: Schema.optional(Schema.String), + id: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + method: Schema.optional(Schema.String), + params: Schema.optional(Schema.Unknown), + result: Schema.optional(Schema.Unknown), + error: Schema.optional(JsonRpcErrorPayload), +}); + +export const AcpInboundResponse = Schema.Struct({ + _tag: Schema.Literal("response"), + id: Schema.Union([Schema.String, Schema.Number]), + result: Schema.optional(Schema.Unknown), + error: Schema.optional(JsonRpcErrorPayload), +}); + +export const AcpInboundRequest = Schema.Struct({ + _tag: Schema.Literal("request"), + id: Schema.Union([Schema.String, Schema.Number]), + method: Schema.String, + params: Schema.optional(Schema.Unknown), +}); + +export const AcpInboundNotification = Schema.Struct({ + _tag: Schema.Literal("notification"), + method: Schema.String, + params: Schema.optional(Schema.Unknown), +}); + +/** + * Inbound JSON-RPC messages from the ACP agent (stdout), after line framing. + */ +export const AcpInboundMessage = Schema.Union([ + AcpInboundResponse, + AcpInboundRequest, + AcpInboundNotification, +]); + +export type AcpInboundMessage = typeof AcpInboundMessage.Type; + +const jsonRpcWireToInbound = SchemaTransformation.transformOrFail({ + decode: (parsed: typeof JsonRpcInboundWire.Type) => { + const id = parsed.id; + const method = parsed.method; + const hasId = id !== undefined && id !== null; + const hasMethod = typeof method === "string"; + + if (hasId && (parsed.result !== undefined || parsed.error !== undefined)) { + const err = parsed.error; + const rpcError = + err !== undefined + ? { + code: err.code, + message: err.message, + ...(err.data !== undefined ? { data: err.data } : {}), + } + : undefined; + return Effect.succeed({ + _tag: "response" as const, + id, + ...(parsed.result !== undefined ? { result: parsed.result } : {}), + ...(rpcError ? { error: rpcError } : {}), + }); + } + + if (hasMethod && hasId) { + return Effect.succeed({ + _tag: "request" as const, + id, + method, + ...(parsed.params !== undefined ? { params: parsed.params } : {}), + }); + } + + if (hasMethod && !hasId) { + return Effect.succeed({ + _tag: "notification" as const, + method, + ...(parsed.params !== undefined ? { params: parsed.params } : {}), + }); + } + + return Effect.fail( + new SchemaIssue.InvalidValue(Option.some(parsed), { + title: "Unrecognized JSON-RPC inbound message shape", + }), + ); + }, + + encode: (msg: AcpInboundMessage) => { + if (msg._tag === "response") { + return Effect.succeed({ + jsonrpc: "2.0" as const, + id: msg.id, + ...(msg.result !== undefined ? { result: msg.result } : {}), + ...(msg.error !== undefined ? { error: msg.error } : {}), + }); + } + if (msg._tag === "request") { + return Effect.succeed({ + jsonrpc: "2.0" as const, + id: msg.id, + method: msg.method, + ...(msg.params !== undefined ? { params: msg.params } : {}), + }); + } + return Effect.succeed({ + jsonrpc: "2.0" as const, + method: msg.method, + ...(msg.params !== undefined ? { params: msg.params } : {}), + }); + }, +}); + +const jsonRpcWireDecodedToInbound = JsonRpcInboundWire.pipe( + Schema.decodeTo(Schema.toType(AcpInboundMessage), jsonRpcWireToInbound), +); + +/** Decode one NDJSON line (JSON string) to a classified inbound message. */ +export const AcpInboundFromJsonLine = Schema.fromJsonString(jsonRpcWireDecodedToInbound); + +export const decodeAcpInboundFromJsonLine = Schema.decodeEffect(AcpInboundFromJsonLine); + +export interface AcpSpawnInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string; + /** Merged with `process.env` for the child. */ + readonly env?: Readonly>; +} + +export type AcpServerRequestHandler = ( + params: unknown, + requestId: number | string, +) => Effect.Effect; diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts new file mode 100644 index 00000000000..9114b88ade2 --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -0,0 +1,34 @@ +/** + * Optional integration check against a real `agent acp` install. + * Enable with: T3_CURSOR_ACP_PROBE=1 bun run test --filter CursorAcpCliProbe + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect } from "effect"; +import { describe, expect } from "vitest"; + +import { makeAcpJsonRpcConnection } from "./AcpJsonRpcConnection.ts"; + +describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => { + it.effect("initialize and authenticate against real agent acp", () => + Effect.gen(function* () { + const conn = yield* makeAcpJsonRpcConnection({ + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }); + + const init = yield* conn.request("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }); + expect(init).toBeDefined(); + + yield* conn.request("authenticate", { methodId: "cursor_login" }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/acp/index.ts b/apps/server/src/provider/acp/index.ts new file mode 100644 index 00000000000..ca1d664a702 --- /dev/null +++ b/apps/server/src/provider/acp/index.ts @@ -0,0 +1,3 @@ +export * from "./AcpErrors.ts"; +export * from "./AcpTypes.ts"; +export * from "./AcpJsonRpcConnection.ts"; diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index a8c1a13f7f2..6537511e730 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,6 +19,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus" import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -78,9 +79,13 @@ export function makeServerProviderLayer(): Layer.Layer< const claudeAdapterLayer = makeClaudeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const cursorAdapterLayer = makeCursorAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), + Layer.provide(cursorAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index f638e7fdfab..844e5d2ceff 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -101,7 +101,7 @@ export class ServerSettingsService extends ServiceMap.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; +const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "cursor"]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 849f59e0881..6337282d298 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, + type CursorModelOptions, type MessageId, type ModelSelection, type ProjectScript, @@ -22,7 +23,14 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + getDefaultModel, + isCursorModelFamilySlug, + normalizeModelSlug, + parseCursorModelSelection, + resolveModelSlugForProvider, +} from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -276,6 +284,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); + const setComposerDraftProviderModelOptions = useComposerDraftStore( + (store) => store.setProviderModelOptions, + ); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, @@ -1013,6 +1024,7 @@ export default function ChatView({ threadId }: ChatViewProps) { codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], claudeAgent: providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [], }), [providerStatuses], ); @@ -3118,6 +3130,33 @@ export default function ChatView({ threadId }: ChatViewProps) { providerStatuses, model, ); + if ( + resolvedProvider === "cursor" && + isCursorModelFamilySlug(resolvedModel) && + activeThread.id.length > 0 + ) { + const prevDraft = useComposerDraftStore.getState().draftsByThreadId[activeThread.id]; + const prevCursorSelection = prevDraft?.modelSelectionByProvider?.cursor; + const prevModelRaw = + prevCursorSelection?.model ?? + (typeof activeThread.modelSelection?.model === "string" + ? resolveModelSlugForProvider("cursor", activeThread.modelSelection.model) + : null) ?? + getDefaultModel("cursor"); + const prevResolved = resolveAppModelSelection( + "cursor", + settings, + providerStatuses, + prevModelRaw, + ); + const prevCursorOptions = prevCursorSelection?.options as CursorModelOptions | undefined; + const prevFamily = parseCursorModelSelection(prevResolved, prevCursorOptions).family; + if (prevFamily !== resolvedModel) { + setComposerDraftProviderModelOptions(activeThread.id, "cursor", null, { + persistSticky: true, + }); + } + } const nextModelSelection: ModelSelection = { provider: resolvedProvider, model: resolvedModel, @@ -3131,6 +3170,7 @@ export default function ChatView({ threadId }: ChatViewProps) { lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, + setComposerDraftProviderModelOptions, setStickyComposerModelSelection, providerStatuses, settings, @@ -3808,6 +3848,11 @@ export default function ChatView({ threadId }: ChatViewProps) { lockedProvider={lockedProvider} providers={providerStatuses} modelOptionsByProvider={modelOptionsByProvider} + cursorModelOptions={ + selectedProvider === "cursor" + ? (composerModelOptions?.cursor ?? null) + : null + } {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index e64a981eec7..f2f1b1b7034 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -63,6 +63,7 @@ function createBaseServerConfig(): ServerConfig { providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, }, }, }; diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 8770e581386..e4901f2b2e7 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ModelSelection, ThreadId } from "@t3tools/contracts"; import "../../index.css"; import { page } from "vitest/browser"; @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { CursorTraitsMenuContent } from "./CursorTraitsPicker"; import { TraitsMenuContent } from "./TraitsPicker"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -91,22 +92,24 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str }, }, ] - : [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], + : provider === "codex" + ? [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, }, - }, - ]; + ] + : []; const screen = await render( + provider === "cursor" ? ( + + ) : ( + + ) } onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} @@ -216,6 +223,24 @@ describe("CompactComposerControlsMenu", () => { }); }); + it("shows Cursor reasoning controls for GPT-5.3 Codex family", async () => { + const mounted = await mountMenu({ + modelSelection: { provider: "cursor", model: "gpt-5.3-codex-high" }, + }); + + try { + await page.getByLabelText("More composer controls").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Reasoning"); + expect(text).toContain("Fast mode"); + }); + } finally { + await mounted.cleanup(); + } + }); + it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => { await using _ = await mountMenu({ modelSelection: { diff --git a/apps/web/src/components/chat/CursorTraitsPicker.tsx b/apps/web/src/components/chat/CursorTraitsPicker.tsx new file mode 100644 index 00000000000..fe589049fd8 --- /dev/null +++ b/apps/web/src/components/chat/CursorTraitsPicker.tsx @@ -0,0 +1,238 @@ +import { + CURSOR_CLAUDE_OPUS_TIER_OPTIONS, + CURSOR_REASONING_OPTIONS, + type CursorReasoningOption, + type ThreadId, +} from "@t3tools/contracts"; +import type { CursorModelOptions } from "@t3tools/contracts"; +import { + cursorFamilySupportsFastWithReasoning, + cursorSelectionToPersistedModelOptions, + getCursorModelCapabilities, + parseCursorModelSelection, +} from "@t3tools/shared/model"; +import { memo, useCallback, useState } from "react"; +import { ChevronDownIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { + Menu, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuTrigger, +} from "../ui/menu"; +import { useComposerDraftStore } from "../../composerDraftStore"; + +const CURSOR_REASONING_LABELS: Record = { + low: "Low", + normal: "Normal", + high: "High", + xhigh: "Extra high", +}; + +export const CursorTraitsMenuContent = memo(function CursorTraitsMenuContentImpl({ + threadId, + model, + cursorModelOptions, +}: { + threadId: ThreadId; + model: string | null | undefined; + cursorModelOptions: CursorModelOptions | null; +}) { + const setModelSelection = useComposerDraftStore((s) => s.setModelSelection); + const setStickyModelSelection = useComposerDraftStore((s) => s.setStickyModelSelection); + const setProviderModelOptions = useComposerDraftStore((s) => s.setProviderModelOptions); + + const selection = parseCursorModelSelection(model, cursorModelOptions); + const capability = getCursorModelCapabilities(selection.family); + + const applyNextSelection = useCallback( + (nextSel: typeof selection) => { + const persisted = cursorSelectionToPersistedModelOptions(nextSel); + const nextModelSelection = { provider: "cursor" as const, model: nextSel.family }; + setModelSelection(threadId, nextModelSelection); + setProviderModelOptions(threadId, "cursor", persisted, { persistSticky: true }); + setStickyModelSelection(nextModelSelection); + }, + [setModelSelection, setProviderModelOptions, setStickyModelSelection, threadId], + ); + + const showFast = + capability.supportsFast && + cursorFamilySupportsFastWithReasoning(selection.family, selection.reasoning); + + if ( + !capability.supportsReasoning && + !showFast && + !capability.supportsThinking && + !capability.supportsClaudeOpusTier + ) { + return null; + } + + return ( + <> + {capability.supportsClaudeOpusTier ? ( + +
+ Opus tier +
+ { + const nextTier = CURSOR_CLAUDE_OPUS_TIER_OPTIONS.find((t) => t === value); + if (!nextTier) return; + applyNextSelection({ + ...selection, + claudeOpusTier: nextTier, + }); + }} + > + High + Max + +
+ ) : null} + {capability.supportsReasoning ? ( + +
+ Reasoning +
+ { + const nextReasoning = CURSOR_REASONING_OPTIONS.find((o) => o === value); + if (!nextReasoning) return; + applyNextSelection({ + ...selection, + reasoning: nextReasoning, + }); + }} + > + {CURSOR_REASONING_OPTIONS.map((option) => ( + + {CURSOR_REASONING_LABELS[option]} + {option === capability.defaultReasoning ? " (default)" : ""} + + ))} + +
+ ) : null} + {showFast ? ( + <> + {capability.supportsReasoning || capability.supportsClaudeOpusTier ? ( + + ) : null} + +
Fast mode
+ { + applyNextSelection({ + ...selection, + fast: value === "on", + }); + }} + > + Off + On + +
+ + ) : null} + {capability.supportsThinking ? ( + <> + {capability.supportsReasoning || showFast || capability.supportsClaudeOpusTier ? ( + + ) : null} + +
Thinking
+ { + applyNextSelection({ + ...selection, + thinking: value === "on", + }); + }} + > + Off + On (default) + +
+ + ) : null} + + ); +}); + +export const CursorTraitsPicker = memo(function CursorTraitsPicker({ + threadId, + model, + cursorModelOptions, +}: { + threadId: ThreadId; + model: string | null | undefined; + cursorModelOptions: CursorModelOptions | null; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const selection = parseCursorModelSelection(model, cursorModelOptions); + const capability = getCursorModelCapabilities(selection.family); + + const showFastTrigger = + capability.supportsFast && + cursorFamilySupportsFastWithReasoning(selection.family, selection.reasoning); + + const triggerLabel = [ + capability.supportsClaudeOpusTier + ? selection.claudeOpusTier === "max" + ? "Max" + : "High" + : null, + capability.supportsReasoning ? CURSOR_REASONING_LABELS[selection.reasoning] : null, + showFastTrigger && selection.fast ? "Fast" : null, + capability.supportsThinking ? `Thinking ${selection.thinking ? "on" : "off"}` : null, + ] + .filter(Boolean) + .join(" · "); + + if ( + !capability.supportsReasoning && + !showFastTrigger && + !capability.supportsThinking && + !capability.supportsClaudeOpusTier + ) { + return null; + } + + return ( + { + setIsMenuOpen(open); + }} + > + + } + > + {triggerLabel.length > 0 ? triggerLabel : "Traits"} + + + + + + ); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3174030eff..aac7603efe8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -860,7 +860,13 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); - const preview = workEntryPreview(workEntry); + const rawPreview = workEntryPreview(workEntry); + const preview = + rawPreview && + normalizeCompactToolLabel(rawPreview).toLowerCase() === + normalizeCompactToolLabel(heading).toLowerCase() + ? null + : rawPreview; const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index fe878e7c189..a2039561c19 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,4 +1,9 @@ -import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { + CURSOR_MODEL_FAMILY_OPTIONS, + type ModelSlug, + type ProviderKind, + type ServerProvider, +} from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -116,12 +121,15 @@ async function mountPicker(props: { document.body.append(host); const onProviderModelChange = vi.fn(); const providers = props.providers ?? TEST_PROVIDERS; - const modelOptionsByProvider = getCustomModelOptionsByProvider( - DEFAULT_UNIFIED_SETTINGS, - providers, - props.provider, - props.model, - ); + const modelOptionsByProvider = { + ...getCustomModelOptionsByProvider( + DEFAULT_UNIFIED_SETTINGS, + providers, + props.provider, + props.model, + ), + cursor: [...CURSOR_MODEL_FAMILY_OPTIONS], + }; const screen = await render( , @@ -236,6 +245,23 @@ describe("ProviderModelPicker", () => { } }); + it("keeps Cursor submenu values as family keys (traits resolve the CLI slug)", async () => { + const mounted = await mountPicker({ + provider: "cursor", + model: "claude-4.6-opus-high-thinking", + lockedProvider: "cursor", + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Codex 5.3" }).click(); + + expect(mounted.onProviderModelChange).toHaveBeenCalledWith("cursor", "gpt-5.3-codex"); + } finally { + await mounted.cleanup(); + } + }); + it("dispatches the canonical slug when a model is selected", async () => { const mounted = await mountPicker({ provider: "claudeAgent", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 5a09defc726..7bc078a84bb 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,5 +1,17 @@ -import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; -import { resolveSelectableModel } from "@t3tools/shared/model"; +import { + CURSOR_MODEL_FAMILY_OPTIONS, + MODEL_OPTIONS_BY_PROVIDER, + type CursorModelOptions, + type ModelSlug, + type ProviderKind, + type ServerProvider, +} from "@t3tools/contracts"; +import { + isCursorModelFamilySlug, + parseCursorModelSelection, + resolveModelSlugForProvider, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; @@ -61,27 +73,56 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { disabled?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; + disabledReason?: string; + cursorModelOptions: CursorModelOptions | null; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; + const cursorFamilyLabel = (() => { + if (activeProvider !== "cursor") return null; + const family = parseCursorModelSelection(props.model, props.cursorModelOptions).family; + const entry = CURSOR_MODEL_FAMILY_OPTIONS.find((o) => o.slug === family); + return entry?.name ?? null; + })(); const selectedModelLabel = - selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; + activeProvider === "cursor" + ? (cursorFamilyLabel ?? + MODEL_OPTIONS_BY_PROVIDER.cursor.find((option) => option.slug === props.model)?.name ?? + props.model) + : (selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? + props.model); const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; const handleModelChange = (provider: ProviderKind, value: string) => { if (props.disabled) return; if (!value) return; - const resolvedModel = resolveSelectableModel( - provider, - value, - props.modelOptionsByProvider[provider], - ); + let resolvedModel: ModelSlug | null = null; + if (provider === "cursor") { + if (isCursorModelFamilySlug(value)) { + resolvedModel = value as ModelSlug; + } else { + resolvedModel = + resolveSelectableModel(provider, value, props.modelOptionsByProvider[provider]) ?? + resolveModelSlugForProvider(provider, value); + } + } else { + resolvedModel = resolveSelectableModel( + provider, + value, + props.modelOptionsByProvider[provider], + ); + } if (!resolvedModel) return; props.onProviderModelChange(provider, resolvedModel); setIsMenuOpen(false); }; + const cursorRadioValue = + activeProvider === "cursor" + ? parseCursorModelSelection(props.model, props.cursorModelOptions).family + : ""; + return ( } > @@ -129,7 +171,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {props.lockedProvider !== null ? ( handleModelChange(props.lockedProvider!, value)} > {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( @@ -187,7 +229,13 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { handleModelChange(option.value, value)} > {props.modelOptionsByProvider[option.value].map((modelOption) => ( diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index d5fbb1333a8..9290f8b0559 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -237,6 +237,40 @@ describe("getComposerProviderState", () => { }); }); + it("returns minimal state for Cursor without trait controls", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "auto", + models: [], + prompt: "", + modelOptions: undefined, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: null, + modelOptionsForDispatch: undefined, + }); + }); + + it("dispatches Cursor fast traits separately from the family model key", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "composer-2", + models: [], + prompt: "", + modelOptions: { + cursor: { fastMode: true }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: null, + modelOptionsForDispatch: { fastMode: true }, + }); + }); + it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => { const state = getComposerProviderState({ provider: "claudeAgent", diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 2cebd8d4f45..ee15f390d6b 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -7,10 +7,12 @@ import { } from "@t3tools/contracts"; import { isClaudeUltrathinkPrompt, + normalizeCursorModelOptions, trimOrNull, getDefaultEffort, hasEffortLevel, } from "@t3tools/shared/model"; +import type { CursorModelOptions } from "@t3tools/contracts"; import type { ReactNode } from "react"; import { getProviderModelCapabilities, @@ -18,6 +20,7 @@ import { normalizeCodexModelOptionsWithCapabilities, } from "../../providerModels"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; +import { CursorTraitsMenuContent, CursorTraitsPicker } from "./CursorTraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; @@ -171,6 +174,30 @@ const composerProviderRegistry: Record = { /> ), }, + cursor: { + getState: ({ model, modelOptions }) => { + const normalized = normalizeCursorModelOptions(model, modelOptions?.cursor); + return { + provider: "cursor" as const, + promptEffort: null, + modelOptionsForDispatch: normalized ?? undefined, + }; + }, + renderTraitsMenuContent: ({ threadId, model, modelOptions }) => ( + + ), + renderTraitsPicker: ({ threadId, model, modelOptions }) => ( + + ), + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3d54c526f1b..75dfe324ab3 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,7 +1,11 @@ import { CODEX_REASONING_EFFORT_OPTIONS, + CURSOR_REASONING_OPTIONS, type ClaudeCodeEffort, type CodexReasoningEffort, + type CursorModelOptions, + type CursorReasoningOption, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, type ModelSlug, ModelSelection, ProjectId, @@ -407,7 +411,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" || value === "claudeAgent" ? value : null; + return value === "codex" || value === "claudeAgent" || value === "cursor" ? value : null; } function normalizeProviderModelOptions( @@ -424,6 +428,10 @@ function normalizeProviderModelOptions( candidate?.claudeAgent && typeof candidate.claudeAgent === "object" ? (candidate.claudeAgent as Record) : null; + const cursorCandidate = + candidate?.cursor && typeof candidate.cursor === "object" + ? (candidate.cursor as Record) + : null; const codexReasoningEffort: CodexReasoningEffort | undefined = codexCandidate?.reasoningEffort === "low" || @@ -484,12 +492,41 @@ function normalizeProviderModelOptions( } : undefined; - if (!codex && !claude) { + const cursorReasoningRaw = cursorCandidate?.reasoning; + const cursorReasoning: CursorReasoningOption | undefined = + typeof cursorReasoningRaw === "string" && + (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorReasoningRaw) + ? (cursorReasoningRaw as CursorReasoningOption) + : undefined; + const cursorFastMode = cursorCandidate?.fastMode === true; + const cursorThinkingFalse = cursorCandidate?.thinking === false; + const cursorClaudeOpusTierRaw = cursorCandidate?.claudeOpusTier; + const cursorClaudeOpusTier = + cursorClaudeOpusTierRaw === "max" || cursorClaudeOpusTierRaw === "high" + ? cursorClaudeOpusTierRaw + : undefined; + const defaultCursorReasoning = + DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor as CursorReasoningOption; + + const cursor: CursorModelOptions | undefined = + cursorCandidate !== null + ? { + ...(cursorReasoning && cursorReasoning !== defaultCursorReasoning + ? { reasoning: cursorReasoning } + : {}), + ...(cursorFastMode ? { fastMode: true } : {}), + ...(cursorThinkingFalse ? { thinking: false } : {}), + ...(cursorClaudeOpusTier === "max" ? { claudeOpusTier: "max" } : {}), + } + : undefined; + + if (!codex && !claude && cursor === undefined) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), + ...(cursor !== undefined ? { cursor } : {}), }; } diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 98e2884adfe..f7b6e627363 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -45,6 +45,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record 0, ), + cursor: Boolean( + settings.providers.cursor.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.cursor.binaryPath || + settings.providers.cursor.apiEndpoint !== + DEFAULT_UNIFIED_SETTINGS.providers.cursor.apiEndpoint || + settings.providers.cursor.customModels.length > 0, + ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ codex: "", claudeAgent: "", + cursor: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -553,10 +567,12 @@ function SettingsRouteView() { setOpenProviderDetails({ codex: false, claudeAgent: false, + cursor: false, }); setCustomModelInputByProvider({ codex: "", claudeAgent: "", + cursor: "", }); setCustomModelErrorByProvider({}); } @@ -836,6 +852,7 @@ function SettingsRouteView() { lockedProvider={null} providers={serverProviders} modelOptionsByProvider={gitModelOptionsByProvider} + cursorModelOptions={null} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onProviderModelChange={(provider, model) => { @@ -1240,7 +1257,9 @@ function SettingsRouteView() { placeholder={ providerCard.provider === "codex" ? "gpt-6.7-codex-ultra-preview" - : "claude-sonnet-5-0" + : providerCard.provider === "cursor" + ? "claude-4.6-sonnet-medium-thinking" + : "claude-sonnet-5-0" } spellCheck={false} /> diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index c786ffc72bf..564ee1e61af 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -768,6 +768,164 @@ describe("deriveWorkLogEntries", () => { ]); }); + it("drops duplicated tool detail when it only repeats the title", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "read-file-generic", + kind: "tool.completed", + summary: "Read File", + payload: { + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.toolTitle).toBe("Read File"); + expect(entry?.detail).toBeUndefined(); + }); + + it("uses grep raw output summaries instead of repeating the generic tool label", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "grep-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "grep", + payload: { + itemType: "web_search", + title: "grep", + detail: "grep", + data: { + toolCallId: "tool-grep-1", + kind: "search", + rawInput: {}, + }, + }, + }), + makeActivity({ + id: "grep-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "grep", + payload: { + itemType: "web_search", + title: "grep", + detail: "grep", + data: { + toolCallId: "tool-grep-1", + kind: "search", + rawOutput: { + totalFiles: 19, + truncated: false, + }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "grep-complete", + toolTitle: "grep", + detail: "19 files", + itemType: "web_search", + }); + }); + + it("uses completed read-file output previews and still collapses the same tool call", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "read-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Read File", + payload: { + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + data: { + toolCallId: "tool-read-1", + kind: "read", + rawInput: {}, + }, + }, + }), + makeActivity({ + id: "read-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Read File", + payload: { + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + data: { + toolCallId: "tool-read-1", + kind: "read", + rawOutput: { + content: + 'import * as Effect from "effect/Effect"\nimport * as Layer from "effect/Layer"\n', + }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "read-complete", + toolTitle: "Read File", + detail: 'import * as Effect from "effect/Effect"', + itemType: "dynamic_tool_call", + }); + }); + + it("collapses legacy completed tool rows that are missing tool metadata", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "legacy-read-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Read File", + payload: { + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + data: { + toolCallId: "tool-read-legacy", + kind: "read", + rawInput: {}, + }, + }, + }), + makeActivity({ + id: "legacy-read-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Read File", + payload: { + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "legacy-read-complete", + toolTitle: "Read File", + itemType: "dynamic_tool_call", + }); + expect(entries[0]?.detail).toBeUndefined(); + }); + it("collapses repeated lifecycle updates for the same tool call into one entry", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1129,13 +1287,13 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("advertises Claude as available while keeping Cursor as a placeholder", () => { + it("advertises Codex, Claude, and Cursor as available providers", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, - { value: "cursor", label: "Cursor", available: false }, + { value: "cursor", label: "Cursor", available: true }, ]); expect(claude).toEqual({ value: "claudeAgent", @@ -1145,7 +1303,7 @@ describe("PROVIDER_OPTIONS", () => { expect(cursor).toEqual({ value: "cursor", label: "Cursor", - available: false, + available: true, }); }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 83a95d63132..637c1472d2d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -20,7 +20,7 @@ import type { TurnDiffSummary, } from "./types"; -export type ProviderPickerKind = ProviderKind | "cursor"; +export type ProviderPickerKind = ProviderKind; export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; @@ -29,7 +29,7 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, - { value: "cursor", label: "Cursor", available: false }, + { value: "cursor", label: "Cursor", available: true }, ]; export interface WorkLogEntry { @@ -48,6 +48,7 @@ export interface WorkLogEntry { interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; + toolCallId?: string; } export interface PendingApproval { @@ -492,6 +493,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const command = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); + const detail = extractToolDetail(payload, title ?? activity.summary); + const toolCallId = extractToolCallId(payload); const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, @@ -501,11 +504,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); - if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { - const detail = stripTrailingExitCode(payload.detail).output; - if (detail) { - entry.detail = detail; - } + if (detail) { + entry.detail = detail; } if (command) { entry.command = command; @@ -522,6 +522,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (requestKind) { entry.requestKind = requestKind; } + if (toolCallId) { + entry.toolCallId = toolCallId; + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; @@ -557,7 +560,16 @@ function shouldCollapseToolLifecycleEntries( if (previous.activityKind === "tool.completed") { return false; } - return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey; + if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) { + return true; + } + return ( + previous.toolCallId !== undefined && + next.toolCallId === undefined && + previous.itemType === next.itemType && + normalizeCompactToolLabel(previous.toolTitle ?? previous.label) === + normalizeCompactToolLabel(next.toolTitle ?? next.label) + ); } function mergeDerivedWorkLogEntries( @@ -571,6 +583,7 @@ function mergeDerivedWorkLogEntries( const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; + const toolCallId = next.toolCallId ?? previous.toolCallId; return { ...previous, ...next, @@ -581,6 +594,7 @@ function mergeDerivedWorkLogEntries( ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), + ...(toolCallId ? { toolCallId } : {}), }; } @@ -599,6 +613,9 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; } + if (entry.toolCallId) { + return `tool:${entry.toolCallId}`; + } const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); const detail = entry.detail?.trim() ?? ""; const itemType = entry.itemType ?? ""; @@ -636,6 +653,10 @@ function asTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + function normalizeCommandValue(value: unknown): string | null { const direct = asTrimmedString(value); if (direct) { @@ -668,6 +689,95 @@ function extractToolTitle(payload: Record | null): string | nul return asTrimmedString(payload?.title); } +function extractToolCallId(payload: Record | null): string | null { + const data = asRecord(payload?.data); + return asTrimmedString(data?.toolCallId); +} + +function normalizeInlinePreview(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function truncateInlinePreview(value: string, maxLength = 84): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength - 1).trimEnd()}…`; +} + +function normalizePreviewForComparison(value: string | null | undefined): string | null { + const normalized = asTrimmedString(value); + if (!normalized) { + return null; + } + return normalizeCompactToolLabel(normalizeInlinePreview(normalized)).toLowerCase(); +} + +function summarizeToolTextOutput(value: string): string | null { + const lines = value + .split(/\r?\n/u) + .map((line) => normalizeInlinePreview(line)) + .filter((line) => line.length > 0); + const firstLine = lines.find((line) => line !== "```"); + if (firstLine) { + return truncateInlinePreview(firstLine); + } + if (lines.length > 1) { + return `${lines.length.toLocaleString()} lines`; + } + return null; +} + +function summarizeToolRawOutput(payload: Record | null): string | null { + const data = asRecord(payload?.data); + const rawOutput = asRecord(data?.rawOutput); + if (!rawOutput) { + return null; + } + + const totalFiles = asNumber(rawOutput.totalFiles); + if (totalFiles !== null) { + const suffix = rawOutput.truncated === true ? "+" : ""; + return `${totalFiles.toLocaleString()} file${totalFiles === 1 ? "" : "s"}${suffix}`; + } + + const content = asTrimmedString(rawOutput.content); + if (content) { + return summarizeToolTextOutput(content); + } + + const stdout = asTrimmedString(rawOutput.stdout); + if (stdout) { + return summarizeToolTextOutput(stdout); + } + + return null; +} + +function extractToolDetail( + payload: Record | null, + heading: string, +): string | null { + const rawDetail = asTrimmedString(payload?.detail); + const detail = rawDetail ? stripTrailingExitCode(rawDetail).output : null; + const normalizedHeading = normalizePreviewForComparison(heading); + const normalizedDetail = normalizePreviewForComparison(detail); + + if (detail && normalizedHeading !== normalizedDetail) { + return detail; + } + + const rawOutputSummary = summarizeToolRawOutput(payload); + if (rawOutputSummary) { + const normalizedRawOutputSummary = normalizePreviewForComparison(rawOutputSummary); + if (normalizedRawOutputSummary !== normalizedHeading) { + return rawOutputSummary; + } + } + + return null; +} + function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 4590b2886da..9460181a998 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -193,7 +193,7 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "cursor") { return providerName; } return "codex"; diff --git a/packages/contracts/src/cursorCliModels.json b/packages/contracts/src/cursorCliModels.json new file mode 100644 index 00000000000..a4a07e7d338 --- /dev/null +++ b/packages/contracts/src/cursorCliModels.json @@ -0,0 +1,343 @@ +{ + "probeCommand": "agent models", + "generatedAt": "2026-03-24T01:37:58.372Z", + "agentVersion": "2026.02.27-e7d2ef6", + "models": [ + { + "id": "auto", + "label": "Auto" + }, + { + "id": "composer-2-fast", + "label": "Composer 2 Fast" + }, + { + "id": "composer-2", + "label": "Composer 2" + }, + { + "id": "composer-1.5", + "label": "Composer 1.5" + }, + { + "id": "gpt-5.3-codex-low", + "label": "GPT-5.3 Codex Low" + }, + { + "id": "gpt-5.3-codex-low-fast", + "label": "GPT-5.3 Codex Low Fast" + }, + { + "id": "gpt-5.3-codex", + "label": "GPT-5.3 Codex" + }, + { + "id": "gpt-5.3-codex-fast", + "label": "GPT-5.3 Codex Fast" + }, + { + "id": "gpt-5.3-codex-high", + "label": "GPT-5.3 Codex High" + }, + { + "id": "gpt-5.3-codex-high-fast", + "label": "GPT-5.3 Codex High Fast" + }, + { + "id": "gpt-5.3-codex-xhigh", + "label": "GPT-5.3 Codex Extra High" + }, + { + "id": "gpt-5.3-codex-xhigh-fast", + "label": "GPT-5.3 Codex Extra High Fast" + }, + { + "id": "gpt-5.2", + "label": "GPT-5.2" + }, + { + "id": "gpt-5.3-codex-spark-preview-low", + "label": "GPT-5.3 Codex Spark Low" + }, + { + "id": "gpt-5.3-codex-spark-preview", + "label": "GPT-5.3 Codex Spark" + }, + { + "id": "gpt-5.3-codex-spark-preview-high", + "label": "GPT-5.3 Codex Spark High" + }, + { + "id": "gpt-5.3-codex-spark-preview-xhigh", + "label": "GPT-5.3 Codex Spark Extra High" + }, + { + "id": "gpt-5.2-codex-low", + "label": "GPT-5.2 Codex Low" + }, + { + "id": "gpt-5.2-codex-low-fast", + "label": "GPT-5.2 Codex Low Fast" + }, + { + "id": "gpt-5.2-codex", + "label": "GPT-5.2 Codex" + }, + { + "id": "gpt-5.2-codex-fast", + "label": "GPT-5.2 Codex Fast" + }, + { + "id": "gpt-5.2-codex-high", + "label": "GPT-5.2 Codex High" + }, + { + "id": "gpt-5.2-codex-high-fast", + "label": "GPT-5.2 Codex High Fast" + }, + { + "id": "gpt-5.2-codex-xhigh", + "label": "GPT-5.2 Codex Extra High" + }, + { + "id": "gpt-5.2-codex-xhigh-fast", + "label": "GPT-5.2 Codex Extra High Fast" + }, + { + "id": "gpt-5.1-codex-max-low", + "label": "GPT-5.1 Codex Max Low" + }, + { + "id": "gpt-5.1-codex-max-low-fast", + "label": "GPT-5.1 Codex Max Low Fast" + }, + { + "id": "gpt-5.1-codex-max-medium", + "label": "GPT-5.1 Codex Max" + }, + { + "id": "gpt-5.1-codex-max-medium-fast", + "label": "GPT-5.1 Codex Max Medium Fast" + }, + { + "id": "gpt-5.1-codex-max-high", + "label": "GPT-5.1 Codex Max High" + }, + { + "id": "gpt-5.1-codex-max-high-fast", + "label": "GPT-5.1 Codex Max High Fast" + }, + { + "id": "gpt-5.1-codex-max-xhigh", + "label": "GPT-5.1 Codex Max Extra High" + }, + { + "id": "gpt-5.1-codex-max-xhigh-fast", + "label": "GPT-5.1 Codex Max Extra High Fast" + }, + { + "id": "gpt-5.4-high", + "label": "GPT-5.4 1M High" + }, + { + "id": "gpt-5.4-high-fast", + "label": "GPT-5.4 High Fast" + }, + { + "id": "gpt-5.4-xhigh-fast", + "label": "GPT-5.4 Extra High Fast" + }, + { + "id": "claude-4.6-opus-high-thinking", + "label": "Opus 4.6 1M Thinking" + }, + { + "id": "gpt-5.4-low", + "label": "GPT-5.4 1M Low" + }, + { + "id": "gpt-5.4-medium", + "label": "GPT-5.4 1M" + }, + { + "id": "gpt-5.4-medium-fast", + "label": "GPT-5.4 Fast" + }, + { + "id": "gpt-5.4-xhigh", + "label": "GPT-5.4 1M Extra High" + }, + { + "id": "claude-4.6-sonnet-medium", + "label": "Sonnet 4.6 1M" + }, + { + "id": "claude-4.6-sonnet-medium-thinking", + "label": "Sonnet 4.6 1M Thinking" + }, + { + "id": "claude-4.6-opus-high", + "label": "Opus 4.6 1M" + }, + { + "id": "claude-4.6-opus-max", + "label": "Opus 4.6 1M Max" + }, + { + "id": "claude-4.6-opus-max-thinking", + "label": "Opus 4.6 1M Max Thinking" + }, + { + "id": "claude-4.5-opus-high", + "label": "Opus 4.5" + }, + { + "id": "claude-4.5-opus-high-thinking", + "label": "Opus 4.5 Thinking" + }, + { + "id": "gpt-5.2-low", + "label": "GPT-5.2 Low" + }, + { + "id": "gpt-5.2-low-fast", + "label": "GPT-5.2 Low Fast" + }, + { + "id": "gpt-5.2-fast", + "label": "GPT-5.2 Fast" + }, + { + "id": "gpt-5.2-high", + "label": "GPT-5.2 High" + }, + { + "id": "gpt-5.2-high-fast", + "label": "GPT-5.2 High Fast" + }, + { + "id": "gpt-5.2-xhigh", + "label": "GPT-5.2 Extra High" + }, + { + "id": "gpt-5.2-xhigh-fast", + "label": "GPT-5.2 Extra High Fast" + }, + { + "id": "gemini-3.1-pro", + "label": "Gemini 3.1 Pro" + }, + { + "id": "gpt-5.4-mini-none", + "label": "GPT-5.4 Mini None" + }, + { + "id": "gpt-5.4-mini-low", + "label": "GPT-5.4 Mini Low" + }, + { + "id": "gpt-5.4-mini-medium", + "label": "GPT-5.4 Mini" + }, + { + "id": "gpt-5.4-mini-high", + "label": "GPT-5.4 Mini High" + }, + { + "id": "gpt-5.4-mini-xhigh", + "label": "GPT-5.4 Mini Extra High" + }, + { + "id": "gpt-5.4-nano-none", + "label": "GPT-5.4 Nano None" + }, + { + "id": "gpt-5.4-nano-low", + "label": "GPT-5.4 Nano Low" + }, + { + "id": "gpt-5.4-nano-medium", + "label": "GPT-5.4 Nano" + }, + { + "id": "gpt-5.4-nano-high", + "label": "GPT-5.4 Nano High" + }, + { + "id": "gpt-5.4-nano-xhigh", + "label": "GPT-5.4 Nano Extra High" + }, + { + "id": "grok-4-20", + "label": "Grok 4.20" + }, + { + "id": "grok-4-20-thinking", + "label": "Grok 4.20 Thinking" + }, + { + "id": "claude-4.5-sonnet", + "label": "Sonnet 4.5 1M" + }, + { + "id": "claude-4.5-sonnet-thinking", + "label": "Sonnet 4.5 1M Thinking" + }, + { + "id": "gpt-5.1-low", + "label": "GPT-5.1 Low" + }, + { + "id": "gpt-5.1", + "label": "GPT-5.1" + }, + { + "id": "gpt-5.1-high", + "label": "GPT-5.1 High" + }, + { + "id": "gemini-3-pro", + "label": "Gemini 3 Pro" + }, + { + "id": "gemini-3-flash", + "label": "Gemini 3 Flash" + }, + { + "id": "gpt-5.1-codex-mini-low", + "label": "GPT-5.1 Codex Mini Low" + }, + { + "id": "gpt-5.1-codex-mini", + "label": "GPT-5.1 Codex Mini" + }, + { + "id": "gpt-5.1-codex-mini-high", + "label": "GPT-5.1 Codex Mini High" + }, + { + "id": "claude-4-sonnet", + "label": "Sonnet 4" + }, + { + "id": "claude-4-sonnet-1m", + "label": "Sonnet 4 1M" + }, + { + "id": "claude-4-sonnet-thinking", + "label": "Sonnet 4 Thinking" + }, + { + "id": "claude-4-sonnet-1m-thinking", + "label": "Sonnet 4 1M Thinking" + }, + { + "id": "gpt-5-mini", + "label": "GPT-5 Mini" + }, + { + "id": "kimi-k2.5", + "label": "Kimi K2.5" + } + ] +} diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 68ca1104734..edc08f8bec1 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,12 +1,21 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; import type { ProviderKind } from "./orchestration"; +import cursorCliModels from "./cursorCliModels.json" with { type: "json" }; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max", "ultrathink"] as const; export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; -export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeCodeEffort; + +/** Cursor “reasoning” tier for GPT‑5.3 Codex–style families (encoded in model slug). */ +export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const; +export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number]; + +export type ProviderReasoningEffort = + | CodexReasoningEffort + | ClaudeCodeEffort + | CursorReasoningOption; export const CodexModelOptions = Schema.Struct({ reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), @@ -21,9 +30,21 @@ export const ClaudeModelOptions = Schema.Struct({ }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; +export const CURSOR_CLAUDE_OPUS_TIER_OPTIONS = ["high", "max"] as const; +export type CursorClaudeOpusTier = (typeof CURSOR_CLAUDE_OPUS_TIER_OPTIONS)[number]; + +export const CursorModelOptions = Schema.Struct({ + reasoning: Schema.optional(Schema.Literals(CURSOR_REASONING_OPTIONS)), + fastMode: Schema.optional(Schema.Boolean), + thinking: Schema.optional(Schema.Boolean), + claudeOpusTier: Schema.optional(Schema.Literals(CURSOR_CLAUDE_OPUS_TIER_OPTIONS)), +}); +export type CursorModelOptions = typeof CursorModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), claudeAgent: Schema.optional(ClaudeModelOptions), + cursor: Schema.optional(CursorModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -42,11 +63,68 @@ export const ModelCapabilities = Schema.Struct({ }); export type ModelCapabilities = typeof ModelCapabilities.Type; +export type ModelOption = { + readonly slug: string; + readonly name: string; +}; + +type CursorModelFamilyOption = { + readonly slug: string; + readonly name: string; +}; + +/** + * High-level families shown in the Cursor provider submenu (traits refine the concrete slug). + * Slug ids are aligned with `agent models` where possible; synthetic keys (`gpt-5.4-1m`, `claude-4.6-opus`, + * `claude-4.6-sonnet`) are not standalone CLI models — see `packages/shared` resolvers. + * + * Note: `agent models` had no `premium`, `composer-1`, or Claude Haiku 4.5 ids at snapshot time + * (`packages/contracts/src/cursorCliModels.json`). + */ +export const CURSOR_MODEL_FAMILY_OPTIONS = [ + { slug: "auto", name: "Auto" }, + { slug: "composer-2", name: "Composer 2" }, + { slug: "composer-1.5", name: "Composer 1.5" }, + { slug: "gpt-5.3-codex", name: "Codex 5.3" }, + { slug: "gpt-5.3-codex-spark-preview", name: "Codex 5.3 Spark" }, + { slug: "gpt-5.4-1m", name: "GPT 5.4" }, + { slug: "claude-4.6-opus", name: "Claude Opus 4.6" }, + { slug: "claude-4.6-sonnet", name: "Claude Sonnet 4.6" }, + { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, +] as const satisfies readonly CursorModelFamilyOption[]; + +export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"]; + +export const MODEL_OPTIONS_BY_PROVIDER = { + codex: [ + { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, + { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { slug: "gpt-5.2", name: "GPT-5.2" }, + ], + claudeAgent: [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, + ], + cursor: cursorCliModels.models.map((m) => ({ + slug: m.id, + name: m.label, + })) satisfies ReadonlyArray, +} as const satisfies Record; +export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; + export type ModelSlug = string & {}; +/** Any built-in id returned by the Cursor CLI for `--model` (see `cursorCliModels.json`). */ +export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"]; + export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", + cursor: "claude-4.6-opus-high-thinking", }; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; @@ -55,6 +133,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4-mini", claudeAgent: "claude-haiku-4-5", + cursor: "composer-2-fast", }; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { @@ -79,6 +158,25 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { codex: "Codex", claudeAgent: "Claude", + cursor: "Cursor", }; + +export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { + codex: CODEX_REASONING_EFFORT_OPTIONS, + claudeAgent: CLAUDE_CODE_EFFORT_OPTIONS, + cursor: CURSOR_REASONING_OPTIONS, +} as const satisfies Record; + +export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { + codex: "high", + claudeAgent: "high", + cursor: "normal", +} as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 0b40bb6fdf6..ec07187f13a 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,5 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; -import { ClaudeModelOptions, CodexModelOptions } from "./model"; +import { ClaudeModelOptions, CodexModelOptions, CursorModelOptions } from "./model"; import { ApprovalRequestId, CheckpointRef, @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); +export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -58,7 +58,18 @@ export const ClaudeModelSelection = Schema.Struct({ }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; -export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); +export const CursorModelSelection = Schema.Struct({ + provider: Schema.Literal("cursor"), + model: TrimmedNonEmptyString, + options: Schema.optionalKey(CursorModelOptions), +}); +export type CursorModelSelection = typeof CursorModelSelection.Type; + +export const ModelSelection = Schema.Union([ + CodexModelSelection, + ClaudeModelSelection, + CursorModelSelection, +]); export type ModelSelection = typeof ModelSelection.Type; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 37469984de4..a07cf7e2619 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -68,6 +68,26 @@ describe("ProviderSessionStartInput", () => { expect(parsed.modelSelection.options?.fastMode).toBe(true); expect(parsed.runtimeMode).toBe("full-access"); }); + + it("accepts cursor provider", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "cursor", + cwd: "/tmp/workspace", + runtimeMode: "full-access", + modelSelection: { + provider: "cursor", + model: "composer-2", + options: { fastMode: true }, + }, + }); + expect(parsed.provider).toBe("cursor"); + expect(parsed.modelSelection?.provider).toBe("cursor"); + expect(parsed.modelSelection?.model).toBe("composer-2"); + if (parsed.modelSelection?.provider === "cursor") { + expect(parsed.modelSelection.options?.fastMode).toBe(true); + } + }); }); describe("ProviderSendTurnInput", () => { diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 81231d88f68..f4fcf41cfb0 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,6 +24,8 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.message", "claude.sdk.permission", "codex.sdk.thread-event", + "acp.jsonrpc", + "acp.cursor.extension", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8ce01f630c2..a9705ffea9f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -5,6 +5,7 @@ import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; import { ClaudeModelOptions, CodexModelOptions, + CursorModelOptions, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, } from "./model"; import { ModelSelection } from "./orchestration"; @@ -70,6 +71,14 @@ export const ClaudeSettings = Schema.Struct({ }); export type ClaudeSettings = typeof ClaudeSettings.Type; +export const CursorSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("agent"), + apiEndpoint: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), +}); +export type CursorSettings = typeof CursorSettings.Type; + export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultThreadEnvMode: ThreadEnvMode.pipe( @@ -86,6 +95,7 @@ export const ServerSettings = Schema.Struct({ providers: Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), + cursor: CursorSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -113,6 +123,13 @@ const ClaudeModelOptionsPatch = Schema.Struct({ fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode), }); +const CursorModelOptionsPatch = Schema.Struct({ + reasoning: Schema.optionalKey(CursorModelOptions.fields.reasoning), + fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode), + thinking: Schema.optionalKey(CursorModelOptions.fields.thinking), + claudeOpusTier: Schema.optionalKey(CursorModelOptions.fields.claudeOpusTier), +}); + const ModelSelectionPatch = Schema.Union([ Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("codex")), @@ -124,6 +141,11 @@ const ModelSelectionPatch = Schema.Union([ model: Schema.optionalKey(TrimmedNonEmptyString), options: Schema.optionalKey(ClaudeModelOptionsPatch), }), + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("cursor")), + model: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(CursorModelOptionsPatch), + }), ]); const CodexSettingsPatch = Schema.Struct({ @@ -139,6 +161,13 @@ const ClaudeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const CursorSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + apiEndpoint: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), @@ -147,6 +176,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), + cursor: Schema.optionalKey(CursorSettingsPatch), }), ), }); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 31f0d0a112a..16283d52abd 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -2,18 +2,33 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, type ModelCapabilities, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, getDefaultEffort, + getDefaultReasoningEffort, + getEffectiveClaudeCodeEffort, hasEffortLevel, + inferProviderForModel, isClaudeUltrathinkPrompt, + normalizeClaudeModelOptions, + normalizeCodexModelOptions, normalizeModelSlug, + parseCursorModelSelection, + resolveCursorDispatchModel, + resolveCursorModelFromSelection, resolveModelSlug, resolveModelSlugForProvider, + resolveReasoningEffortForProvider, resolveSelectableModel, + supportsClaudeAdaptiveReasoning, + supportsClaudeFastMode, + supportsClaudeMaxEffort, + supportsClaudeThinkingToggle, + supportsClaudeUltrathinkKeyword, trimOrNull, } from "./model"; @@ -89,8 +104,207 @@ describe("capability helpers", () => { }); }); +describe("inferProviderForModel", () => { + it("detects known provider model slugs", () => { + expect(inferProviderForModel("gpt-5.3-codex")).toBe("codex"); + expect(inferProviderForModel("claude-sonnet-4-6")).toBe("claudeAgent"); + expect(inferProviderForModel("sonnet")).toBe("claudeAgent"); + }); + + it("falls back when the model is unknown", () => { + expect(inferProviderForModel("custom/internal-model")).toBe("codex"); + expect(inferProviderForModel("custom/internal-model", "claudeAgent")).toBe("claudeAgent"); + }); + + it("treats claude-prefixed custom slugs as claude", () => { + expect(inferProviderForModel("claude-custom-internal")).toBe("claudeAgent"); + }); + + it("infers cursor from Cursor-only slugs", () => { + expect(inferProviderForModel("claude-4.6-opus-high-thinking")).toBe("cursor"); + expect(inferProviderForModel("composer-1.5")).toBe("cursor"); + }); + + it("infers cursor from family slugs", () => { + expect(inferProviderForModel("composer-2")).toBe("cursor"); + expect(inferProviderForModel("gpt-5.4-1m")).toBe("cursor"); + expect(inferProviderForModel("claude-4.6-opus")).toBe("cursor"); + expect(inferProviderForModel("claude-4.6-sonnet")).toBe("cursor"); + expect(inferProviderForModel("auto")).toBe("cursor"); + }); +}); + +describe("cursor model selection helpers", () => { + it("parses GPT-5.3 Codex reasoning and fast suffixes from slugs", () => { + expect(parseCursorModelSelection("gpt-5.3-codex-high-fast")).toMatchObject({ + family: "gpt-5.3-codex", + reasoning: "high", + fast: true, + thinking: false, + }); + }); + + it("merges persisted cursor modelOptions over the family model key", () => { + expect(parseCursorModelSelection("composer-2", { fastMode: true })).toMatchObject({ + family: "composer-2", + fast: true, + }); + expect(resolveCursorDispatchModel("composer-2", { fastMode: true })).toBe("composer-2-fast"); + expect(resolveCursorDispatchModel("composer-2", undefined)).toBe("composer-2"); + }); + + it("parses and resolves Claude Opus 4.6 tiers and thinking from CLI slugs", () => { + expect(parseCursorModelSelection("claude-4.6-opus-high-thinking")).toMatchObject({ + family: "claude-4.6-opus", + thinking: true, + claudeOpusTier: "high", + }); + expect(parseCursorModelSelection("claude-4.6-opus-max")).toMatchObject({ + claudeOpusTier: "max", + thinking: false, + }); + expect( + resolveCursorModelFromSelection({ + family: "claude-4.6-opus", + thinking: true, + claudeOpusTier: "high", + }), + ).toBe("claude-4.6-opus-high-thinking"); + expect( + resolveCursorModelFromSelection({ + family: "claude-4.6-opus", + thinking: false, + claudeOpusTier: "max", + }), + ).toBe("claude-4.6-opus-max"); + }); +}); + +describe("getDefaultReasoningEffort", () => { + it("returns provider-scoped defaults", () => { + expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex); + expect(getDefaultReasoningEffort("claudeAgent")).toBe( + DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent, + ); + expect(getDefaultReasoningEffort("cursor")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor); + }); +}); + +describe("resolveReasoningEffortForProvider", () => { + it("accepts provider-scoped effort values", () => { + expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh"); + expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink"); + }); + + it("rejects effort values from the wrong provider", () => { + expect(resolveReasoningEffortForProvider("codex", "max")).toBeNull(); + expect(resolveReasoningEffortForProvider("claudeAgent", "xhigh")).toBeNull(); + }); + + it("accepts cursor reasoning tiers", () => { + expect(resolveReasoningEffortForProvider("cursor", "normal")).toBe("normal"); + expect(resolveReasoningEffortForProvider("cursor", "xhigh")).toBe("xhigh"); + }); +}); + +describe("getEffectiveClaudeCodeEffort", () => { + it("does not persist ultrathink into Claude runtime configuration", () => { + expect(getEffectiveClaudeCodeEffort("ultrathink")).toBeNull(); + expect(getEffectiveClaudeCodeEffort("high")).toBe("high"); + }); + + it("returns null when no claude effort is selected", () => { + expect(getEffectiveClaudeCodeEffort(null)).toBeNull(); + expect(getEffectiveClaudeCodeEffort(undefined)).toBeNull(); + }); +}); + +describe("normalizeCodexModelOptions", () => { + it("drops default-only codex options", () => { + expect( + normalizeCodexModelOptions({ reasoningEffort: "high", fastMode: false }), + ).toBeUndefined(); + }); + + it("preserves non-default codex options", () => { + expect(normalizeCodexModelOptions({ reasoningEffort: "xhigh", fastMode: true })).toEqual({ + reasoningEffort: "xhigh", + fastMode: true, + }); + }); +}); + +describe("normalizeClaudeModelOptions", () => { + it("drops unsupported fast mode and max effort for Sonnet", () => { + expect( + normalizeClaudeModelOptions("claude-sonnet-4-6", { + effort: "max", + fastMode: true, + }), + ).toBeUndefined(); + }); + + it("keeps the Haiku thinking toggle and removes unsupported effort", () => { + expect( + normalizeClaudeModelOptions("claude-haiku-4-5", { + thinking: false, + effort: "high", + }), + ).toEqual({ + thinking: false, + }); + }); +}); + +describe("supportsClaudeAdaptiveReasoning", () => { + it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { + expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true); + expect(supportsClaudeAdaptiveReasoning("claude-sonnet-4-6")).toBe(true); + expect(supportsClaudeAdaptiveReasoning("claude-haiku-4-5")).toBe(false); + expect(supportsClaudeAdaptiveReasoning(undefined)).toBe(false); + }); +}); + +describe("supportsClaudeMaxEffort", () => { + it("only enables max effort for Opus 4.6", () => { + expect(supportsClaudeMaxEffort("claude-opus-4-6")).toBe(true); + expect(supportsClaudeMaxEffort("claude-sonnet-4-6")).toBe(false); + expect(supportsClaudeMaxEffort("claude-haiku-4-5")).toBe(false); + expect(supportsClaudeMaxEffort(undefined)).toBe(false); + }); +}); + +describe("supportsClaudeFastMode", () => { + it("only enables Claude fast mode for Opus 4.6", () => { + expect(supportsClaudeFastMode("claude-opus-4-6")).toBe(true); + expect(supportsClaudeFastMode("opus")).toBe(true); + expect(supportsClaudeFastMode("claude-sonnet-4-6")).toBe(false); + expect(supportsClaudeFastMode("claude-haiku-4-5")).toBe(false); + expect(supportsClaudeFastMode(undefined)).toBe(false); + }); +}); + +describe("supportsClaudeUltrathinkKeyword", () => { + it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { + expect(supportsClaudeUltrathinkKeyword("claude-opus-4-6")).toBe(true); + expect(supportsClaudeUltrathinkKeyword("claude-sonnet-4-6")).toBe(true); + expect(supportsClaudeUltrathinkKeyword("claude-haiku-4-5")).toBe(false); + }); +}); + +describe("supportsClaudeThinkingToggle", () => { + it("only enables the Claude thinking toggle for Haiku 4.5", () => { + expect(supportsClaudeThinkingToggle("claude-opus-4-6")).toBe(false); + expect(supportsClaudeThinkingToggle("claude-sonnet-4-6")).toBe(false); + expect(supportsClaudeThinkingToggle("claude-haiku-4-5")).toBe(true); + expect(supportsClaudeThinkingToggle("haiku")).toBe(true); + expect(supportsClaudeThinkingToggle(undefined)).toBe(false); + }); +}); + describe("misc helpers", () => { it("detects ultrathink prompts", () => { + expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); expect(isClaudeUltrathinkPrompt("Investigate")).toBe(false); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index e633aeb2937..e51f966640a 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,12 +1,485 @@ import { + CURSOR_MODEL_FAMILY_OPTIONS, + CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, + MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, + REASONING_EFFORT_OPTIONS_BY_PROVIDER, type ClaudeCodeEffort, + type ClaudeModelOptions, + type CodexModelOptions, + type CodexReasoningEffort, + type CursorClaudeOpusTier, + type CursorModelFamily, + type CursorModelOptions, + type CursorModelSlug, + type CursorReasoningOption, type ModelCapabilities, type ModelSlug, type ProviderKind, + type ProviderReasoningEffort, } from "@t3tools/contracts"; +const MODEL_SLUG_SET_BY_PROVIDER: Record> = { + claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), + codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)), +}; + +type CursorModelCapability = { + readonly supportsReasoning: boolean; + readonly supportsFast: boolean; + readonly supportsThinking: boolean; + readonly supportsClaudeOpusTier: boolean; + readonly defaultReasoning: CursorReasoningOption; + readonly defaultThinking: boolean; + readonly defaultClaudeOpusTier: CursorClaudeOpusTier; +}; + +const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record = { + auto: { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "composer-2": { + supportsReasoning: false, + supportsFast: true, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "composer-1.5": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "gpt-5.3-codex": { + supportsReasoning: true, + supportsFast: true, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "gpt-5.3-codex-spark-preview": { + supportsReasoning: true, + supportsFast: false, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "gpt-5.4-1m": { + supportsReasoning: true, + supportsFast: true, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "claude-4.6-opus": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: true, + supportsClaudeOpusTier: true, + defaultReasoning: "normal", + defaultThinking: true, + defaultClaudeOpusTier: "high", + }, + "claude-4.6-sonnet": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: true, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "gemini-3.1-pro": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, +}; + +const CURSOR_MODEL_FAMILY_SET = new Set( + CURSOR_MODEL_FAMILY_OPTIONS.map((option) => option.slug), +); + +export interface CursorModelSelection { + readonly family: CursorModelFamily; + readonly reasoning: CursorReasoningOption; + readonly fast: boolean; + readonly thinking: boolean; + readonly claudeOpusTier: CursorClaudeOpusTier; +} + +export function getCursorModelFamilyOptions() { + return CURSOR_MODEL_FAMILY_OPTIONS; +} + +export function getCursorModelCapabilities(family: CursorModelFamily) { + return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; +} + +/** Fast toggles are absent for some GPT‑5.4 1M + reasoning combinations in the live CLI model list. */ +export function cursorFamilySupportsFastWithReasoning( + family: CursorModelFamily, + reasoning: CursorReasoningOption, +): boolean { + if (!getCursorModelCapabilities(family).supportsFast) return false; + if (family === "gpt-5.4-1m" && reasoning === "low") return false; + return true; +} + +function fallbackCursorModelFamily(): CursorModelFamily { + return parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor).family; +} + +function resolveCursorModelFamily(model: string | null | undefined): CursorModelFamily { + const normalized = normalizeModelSlug(model, "cursor"); + if (!normalized) { + return fallbackCursorModelFamily(); + } + + if (normalized === "auto") { + return "auto"; + } + + if (normalized === "composer-2" || normalized === "composer-2-fast") { + return "composer-2"; + } + + if (normalized === "composer-1.5") { + return "composer-1.5"; + } + + if (normalized.startsWith("gpt-5.3-codex-spark-preview")) { + return "gpt-5.3-codex-spark-preview"; + } + + if (normalized.startsWith("gpt-5.3-codex")) { + return "gpt-5.3-codex"; + } + + if ( + normalized === "gpt-5.4-low" || + normalized === "gpt-5.4-medium" || + normalized === "gpt-5.4-medium-fast" || + normalized === "gpt-5.4-high" || + normalized === "gpt-5.4-high-fast" || + normalized === "gpt-5.4-xhigh" || + normalized === "gpt-5.4-xhigh-fast" + ) { + return "gpt-5.4-1m"; + } + + if (normalized.startsWith("claude-4.6-opus-")) { + return "claude-4.6-opus"; + } + + if (normalized.startsWith("claude-4.6-sonnet-")) { + return "claude-4.6-sonnet"; + } + + if (normalized === "gemini-3.1-pro") { + return "gemini-3.1-pro"; + } + + return CURSOR_MODEL_FAMILY_SET.has(normalized as CursorModelFamily) + ? (normalized as CursorModelFamily) + : fallbackCursorModelFamily(); +} + +function resolveCursorReasoningFromSlug(model: CursorModelSlug): CursorReasoningOption { + if (model.includes("-xhigh")) return "xhigh"; + if (model.includes("-high")) return "high"; + if (model.includes("-low")) return "low"; + return "normal"; +} + +function parseClaudeOpusFromSlug(slug: string): { + readonly tier: CursorClaudeOpusTier; + readonly thinking: boolean; +} { + return { + tier: slug.includes("opus-max") ? "max" : "high", + thinking: slug.endsWith("-thinking"), + }; +} + +function mergePersistedCursorOptionsOntoSelection( + sel: CursorModelSelection, + cursorOpts: CursorModelOptions | null | undefined, +): CursorModelSelection { + if (!cursorOpts) return sel; + let next: CursorModelSelection = sel; + if ( + typeof cursorOpts.reasoning === "string" && + (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorOpts.reasoning) + ) { + next = { ...next, reasoning: cursorOpts.reasoning }; + } + if (cursorOpts.fastMode === true) { + next = { ...next, fast: true }; + } + if (cursorOpts.fastMode === false) { + next = { ...next, fast: false }; + } + if (cursorOpts.thinking === true) { + next = { ...next, thinking: true }; + } + if (cursorOpts.thinking === false) { + next = { ...next, thinking: false }; + } + if (cursorOpts.claudeOpusTier === "max" || cursorOpts.claudeOpusTier === "high") { + next = { ...next, claudeOpusTier: cursorOpts.claudeOpusTier }; + } + return next; +} + +function parseCursorModelSelectionFromSlugOnly( + model: string | null | undefined, +): CursorModelSelection { + const family = resolveCursorModelFamily(model); + const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; + const normalized = resolveModelSlugForProvider("cursor", model) as CursorModelSlug; + + const base: Pick = { + reasoning: capability.defaultReasoning, + fast: false, + thinking: capability.defaultThinking, + claudeOpusTier: capability.defaultClaudeOpusTier, + }; + + if (capability.supportsReasoning) { + return { + family, + ...base, + reasoning: resolveCursorReasoningFromSlug(normalized), + fast: normalized.endsWith("-fast"), + thinking: false, + claudeOpusTier: "high", + }; + } + + if (family === "claude-4.6-opus") { + const parsed = parseClaudeOpusFromSlug(normalized); + return { + family, + ...base, + reasoning: capability.defaultReasoning, + fast: false, + claudeOpusTier: parsed.tier, + thinking: parsed.thinking, + }; + } + + if (family === "composer-2") { + return { + family, + ...base, + fast: normalized === "composer-2-fast", + thinking: false, + claudeOpusTier: "high", + }; + } + + if (capability.supportsThinking) { + return { + family, + ...base, + reasoning: capability.defaultReasoning, + fast: false, + thinking: normalized.includes("-thinking"), + claudeOpusTier: "high", + }; + } + + return { family, ...base }; +} + +export function parseCursorModelSelection( + model: string | null | undefined, + cursorOpts?: CursorModelOptions | null, +): CursorModelSelection { + return mergePersistedCursorOptionsOntoSelection( + parseCursorModelSelectionFromSlugOnly(model), + cursorOpts, + ); +} + +/** Minimal `cursor` modelOptions for API dispatch (non-default traits only). */ +export function normalizeCursorModelOptions( + model: string | null | undefined, + persisted: CursorModelOptions | null | undefined, +): CursorModelOptions | undefined { + const sel = parseCursorModelSelection(model, persisted); + const cap = getCursorModelCapabilities(sel.family); + const defaultReasoning = DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor as CursorReasoningOption; + const next: { + reasoning?: CursorReasoningOption; + fastMode?: boolean; + thinking?: boolean; + claudeOpusTier?: CursorClaudeOpusTier; + } = {}; + if (cap.supportsReasoning && sel.reasoning !== defaultReasoning) { + next.reasoning = sel.reasoning; + } + if (cap.supportsFast && sel.fast) { + next.fastMode = true; + } + if (cap.supportsThinking && sel.thinking === false) { + next.thinking = false; + } + if (cap.supportsClaudeOpusTier && sel.claudeOpusTier === "max") { + next.claudeOpusTier = "max"; + } + return Object.keys(next).length > 0 ? (next as CursorModelOptions) : undefined; +} + +/** Persisted options for a trait selection (null = all defaults / omit from draft). */ +export function cursorSelectionToPersistedModelOptions( + sel: CursorModelSelection, +): CursorModelOptions | null { + const cap = getCursorModelCapabilities(sel.family); + const defaultReasoning = DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor as CursorReasoningOption; + const next: { + reasoning?: CursorReasoningOption; + fastMode?: boolean; + thinking?: boolean; + claudeOpusTier?: CursorClaudeOpusTier; + } = {}; + if (cap.supportsReasoning && sel.reasoning !== defaultReasoning) { + next.reasoning = sel.reasoning; + } + if (cap.supportsFast && sel.fast) { + next.fastMode = true; + } + if (cap.supportsThinking && sel.thinking === false) { + next.thinking = false; + } + if (cap.supportsClaudeOpusTier && sel.claudeOpusTier === "max") { + next.claudeOpusTier = "max"; + } + return Object.keys(next).length > 0 ? (next as CursorModelOptions) : null; +} + +/** + * Resolves the concrete Cursor CLI `--model` id from the logical family key (or custom slug) plus + * optional persisted `modelOptions.cursor` traits. + */ +export function resolveCursorDispatchModel( + model: string | null | undefined, + cursorOpts: CursorModelOptions | null | undefined, +): string { + const normalized = normalizeModelSlug(model, "cursor") ?? DEFAULT_MODEL_BY_PROVIDER.cursor; + const hasPersistedTraits = Boolean(cursorOpts && Object.keys(cursorOpts).length > 0); + if (hasPersistedTraits && isCursorModelFamilySlug(normalized)) { + const sel = parseCursorModelSelection(normalized, cursorOpts); + return resolveCursorModelFromSelection(sel); + } + return resolveModelSlugForProvider("cursor", normalized); +} + +export function resolveCursorModelFromSelection(input: { + readonly family: CursorModelFamily; + readonly reasoning?: CursorReasoningOption | null; + readonly fast?: boolean | null; + readonly thinking?: boolean | null; + readonly claudeOpusTier?: CursorClaudeOpusTier | null; +}): CursorModelSlug { + const family = resolveCursorModelFamily(input.family); + const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; + + if (family === "composer-2") { + const slug = input.fast === true ? "composer-2-fast" : "composer-2"; + return resolveModelSlugForProvider("cursor", slug) as CursorModelSlug; + } + + if (family === "gpt-5.4-1m") { + const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") + ? (input.reasoning ?? "normal") + : capability.defaultReasoning; + const tier = reasoning === "normal" ? "medium" : reasoning; + const base = `gpt-5.4-${tier}`; + if (input.fast === true) { + const fastSlug = `${base}-fast`; + const candidate = MODEL_SLUG_SET_BY_PROVIDER.cursor.has(fastSlug) ? fastSlug : base; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + return resolveModelSlugForProvider("cursor", base) as CursorModelSlug; + } + + if (family === "gpt-5.3-codex-spark-preview") { + const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") + ? (input.reasoning ?? "normal") + : capability.defaultReasoning; + const suffix = reasoning === "normal" ? "" : `-${reasoning}`; + const candidate = `gpt-5.3-codex-spark-preview${suffix}`; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + + if (capability.supportsReasoning) { + const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") + ? (input.reasoning ?? "normal") + : capability.defaultReasoning; + const reasoningSuffix = reasoning === "normal" ? "" : `-${reasoning}`; + const fastSuffix = input.fast === true ? "-fast" : ""; + const candidate = `${family}${reasoningSuffix}${fastSuffix}`; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + + if (family === "claude-4.6-opus") { + const tier = input.claudeOpusTier === "max" ? "max" : "high"; + const thinking = + input.thinking === false + ? false + : input.thinking === true + ? true + : capability.defaultThinking; + const base = `claude-4.6-opus-${tier}`; + const candidate = thinking ? `${base}-thinking` : base; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + + if (family === "claude-4.6-sonnet") { + const thinking = + input.thinking === false + ? false + : input.thinking === true + ? true + : capability.defaultThinking; + const candidate = thinking ? "claude-4.6-sonnet-medium-thinking" : "claude-4.6-sonnet-medium"; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + + return resolveModelSlugForProvider("cursor", family) as CursorModelSlug; +} + +const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; +const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6"; +const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5"; + export interface SelectableModelOption { slug: string; name: string; @@ -110,6 +583,178 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } +export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean { + const slug = normalizeModelSlug(model, "claudeAgent"); + return slug === CLAUDE_OPUS_4_6_MODEL || slug === CLAUDE_SONNET_4_6_MODEL; +} + +export function supportsClaudeMaxEffort(model: string | null | undefined): boolean { + const slug = normalizeModelSlug(model, "claudeAgent"); + return slug === CLAUDE_OPUS_4_6_MODEL; +} + +export function supportsClaudeFastMode(model: string | null | undefined): boolean { + const slug = normalizeModelSlug(model, "claudeAgent"); + return slug === CLAUDE_OPUS_4_6_MODEL; +} + +export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean { + const slug = normalizeModelSlug(model, "claudeAgent"); + return slug === CLAUDE_OPUS_4_6_MODEL || slug === CLAUDE_SONNET_4_6_MODEL; +} + +export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean { + const slug = normalizeModelSlug(model, "claudeAgent"); + return slug === CLAUDE_HAIKU_4_5_MODEL; +} + +export function inferProviderForModel( + model: string | null | undefined, + fallback: ProviderKind = "codex", +): ProviderKind { + const normalizedClaude = normalizeModelSlug(model, "claudeAgent"); + if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) { + return "claudeAgent"; + } + + const normalizedCodex = normalizeModelSlug(model, "codex"); + if (normalizedCodex && MODEL_SLUG_SET_BY_PROVIDER.codex.has(normalizedCodex)) { + return "codex"; + } + + const normalizedCursor = normalizeModelSlug(model, "cursor"); + if (normalizedCursor && MODEL_SLUG_SET_BY_PROVIDER.cursor.has(normalizedCursor)) { + return "cursor"; + } + + if (typeof model === "string" && CURSOR_MODEL_FAMILY_SET.has(model.trim() as CursorModelFamily)) { + return "cursor"; + } + + return typeof model === "string" && model.trim().startsWith("claude-") ? "claudeAgent" : fallback; +} + +export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray; +export function getReasoningEffortOptions( + provider: "claudeAgent", + model?: string | null | undefined, +): ReadonlyArray; +export function getReasoningEffortOptions( + provider?: ProviderKind, + model?: string | null | undefined, +): ReadonlyArray; +export function getReasoningEffortOptions( + provider: ProviderKind = "codex", + model?: string | null | undefined, +): ReadonlyArray { + if (provider === "claudeAgent") { + if (supportsClaudeMaxEffort(model)) { + return ["low", "medium", "high", "max", "ultrathink"]; + } + if (supportsClaudeAdaptiveReasoning(model)) { + return ["low", "medium", "high", "ultrathink"]; + } + return []; + } + if (provider === "cursor") { + return []; + } + return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; +} + +export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; +export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort; +export function getDefaultReasoningEffort(provider: "cursor"): CursorReasoningOption; +export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort; +export function getDefaultReasoningEffort( + provider: ProviderKind = "codex", +): ProviderReasoningEffort { + return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; +} + +export function resolveReasoningEffortForProvider( + provider: "codex", + effort: string | null | undefined, +): CodexReasoningEffort | null; +export function resolveReasoningEffortForProvider( + provider: "claudeAgent", + effort: string | null | undefined, +): ClaudeCodeEffort | null; +export function resolveReasoningEffortForProvider( + provider: ProviderKind, + effort: string | null | undefined, +): ProviderReasoningEffort | null; +export function resolveReasoningEffortForProvider( + provider: ProviderKind, + effort: string | null | undefined, +): ProviderReasoningEffort | null { + if (typeof effort !== "string") { + return null; + } + + const trimmed = effort.trim(); + if (!trimmed) { + return null; + } + + const options = REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider] as ReadonlyArray; + return options.includes(trimmed) ? (trimmed as ProviderReasoningEffort) : null; +} + +export function isCursorModelFamilySlug(slug: string): boolean { + return CURSOR_MODEL_FAMILY_SET.has(slug as CursorModelFamily); +} + +export function getEffectiveClaudeCodeEffort( + effort: ClaudeCodeEffort | null | undefined, +): Exclude | null { + if (!effort) { + return null; + } + return effort === "ultrathink" ? null : effort; +} + +export function normalizeCodexModelOptions( + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const reasoningEffort = + resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ?? + defaultReasoningEffort; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeClaudeModelOptions( + model: string | null | undefined, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); + const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent"); + const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort); + const effort = + resolvedEffort && + resolvedEffort !== "ultrathink" && + reasoningOptions.includes(resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort + : undefined; + const thinking = + supportsClaudeThinkingToggle(model) && modelOptions?.thinking === false ? false : undefined; + const fastMode = + supportsClaudeFastMode(model) && modelOptions?.fastMode === true ? true : undefined; + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined, diff --git a/scripts/cursor-agent-models-probe.mjs b/scripts/cursor-agent-models-probe.mjs new file mode 100644 index 00000000000..bf56ed0b718 --- /dev/null +++ b/scripts/cursor-agent-models-probe.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * Probes the local Cursor CLI for the authoritative model id list (`agent models`). + * + * Usage: + * node scripts/cursor-agent-models-probe.mjs # print JSON to stdout + * node scripts/cursor-agent-models-probe.mjs --write # write packages/contracts/src/cursorCliModels.json + * node scripts/cursor-agent-models-probe.mjs --check # fail if snapshot is stale vs live CLI + * + * Requires `agent` on PATH (install: Cursor CLI). Uses the same auth as interactive agent. + */ +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, ".."); +const SNAPSHOT_PATH = join(REPO_ROOT, "packages/contracts/src/cursorCliModels.json"); + +const ESC = "\u001B"; +const ANSI = new RegExp(`${ESC}\\[[0-9;]*[a-zA-Z]`, "g"); + +function stripAnsi(text) { + return text.replace(ANSI, ""); +} + +function cleanDisplayLabel(raw) { + return raw + .replace(/\s*\(default\)\s*$/i, "") + .replace(/\s*\(current\)\s*$/i, "") + .trim(); +} + +function parseModelsOutput(text) { + const lines = stripAnsi(text).split("\n"); + const models = []; + for (const line of lines) { + const trimmed = line.trim(); + const m = /^(\S+)\s+-\s+(.+)$/.exec(trimmed); + if (!m) continue; + const id = m[1]; + const label = cleanDisplayLabel(m[2]); + if (id === "Tip:" || id === "Available") continue; + models.push({ id, label }); + } + return models; +} + +function probeLiveModels() { + const r = spawnSync("agent", ["models"], { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + if (r.error) { + throw r.error; + } + if (r.status !== 0) { + throw new Error(r.stderr || `agent models exited ${r.status}`); + } + return parseModelsOutput(r.stdout ?? ""); +} + +function agentVersion() { + const r = spawnSync("agent", ["-v"], { encoding: "utf8" }); + if (r.status !== 0) return null; + return (r.stdout ?? "").trim() || null; +} + +function main() { + const write = process.argv.includes("--write"); + const check = process.argv.includes("--check"); + + const models = probeLiveModels(); + if (models.length === 0) { + console.error( + "cursor-agent-models-probe: no models parsed (is `agent` installed and logged in?)", + ); + process.exit(1); + } + + const payload = { + probeCommand: "agent models", + generatedAt: new Date().toISOString(), + agentVersion: agentVersion(), + models, + }; + + if (write) { + writeFileSync(SNAPSHOT_PATH, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + console.error(`Wrote ${models.length} models to ${SNAPSHOT_PATH}`); + } + + if (check) { + const existing = JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8")); + const want = new Set(existing.models.map((m) => m.id)); + const got = new Set(models.map((m) => m.id)); + const missing = [...want].filter((id) => !got.has(id)); + const extra = [...got].filter((id) => !want.has(id)); + if (missing.length || extra.length) { + console.error("cursor-agent-models-probe: snapshot drift vs live `agent models`"); + if (missing.length) console.error("missing from live:", missing.join(", ")); + if (extra.length) console.error("extra in live:", extra.join(", ")); + console.error("Re-run: node scripts/cursor-agent-models-probe.mjs --write"); + process.exit(1); + } + console.error(`OK: ${models.length} models match ${SNAPSHOT_PATH}`); + } + + if (!write && !check) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } +} + +main(); From 10129edd107bf8ef9bab86f01de67a5640621bbc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 12:48:00 -0700 Subject: [PATCH 02/82] Use server-driven ModelCapabilities for Cursor traits Replace the hardcoded client-side CURSOR_MODEL_CAPABILITY_BY_FAMILY map with server-provided ModelCapabilities, matching the Codex/Claude pattern. - Add CursorProvider snapshot service with BUILT_IN_MODELS and per-model capabilities; register it in ProviderRegistry alongside Codex/Claude. - Delete CursorTraitsPicker and route Cursor through the generic TraitsPicker, adding cursor support for the reasoning/effort key. - Add normalizeCursorModelOptionsWithCapabilities to providerModels. Made-with: Cursor --- .../src/provider/Layers/CursorProvider.ts | 439 ++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 32 +- .../src/provider/Services/CursorProvider.ts | 9 + .../CompactComposerControlsMenu.browser.tsx | 44 +- .../components/chat/CursorTraitsPicker.tsx | 238 ---------- apps/web/src/components/chat/TraitsPicker.tsx | 17 +- .../chat/composerProviderRegistry.test.tsx | 81 +++- .../chat/composerProviderRegistry.tsx | 48 +- apps/web/src/providerModels.ts | 24 + 9 files changed, 645 insertions(+), 287 deletions(-) create mode 100644 apps/server/src/provider/Layers/CursorProvider.ts create mode 100644 apps/server/src/provider/Services/CursorProvider.ts delete mode 100644 apps/web/src/components/chat/CursorTraitsPicker.tsx diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts new file mode 100644 index 00000000000..5300807766e --- /dev/null +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -0,0 +1,439 @@ +import type { + CursorSettings, + ModelCapabilities, + ServerProvider, + ServerProviderModel, + ServerProviderAuthStatus, + ServerProviderState, +} from "@t3tools/contracts"; +import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + collectStreamAsString, + DEFAULT_TIMEOUT_MS, + detailFromResult, + extractAuthBoolean, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + type CommandResult, +} from "../providerSnapshot"; +import { makeManagedServerProvider } from "../makeManagedServerProvider"; +import { CursorProvider } from "../Services/CursorProvider"; +import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; + +const PROVIDER = "cursor" as const; +const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "auto", + name: "Auto", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "composer-1.5", + name: "Composer 1.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "Codex 5.3", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex-spark-preview", + name: "Codex 5.3 Spark", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.4-1m", + name: "GPT 5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-4.6-opus", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-4.6-sonnet", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gemini-3.1-pro", + name: "Gemini 3.1 Pro", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, +]; + +export function getCursorModelCapabilities(model: string | null | undefined): ModelCapabilities { + const slug = model?.trim(); + return ( + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + } + ); +} + +export function parseCursorAuthStatusFromOutput(result: CommandResult): { + readonly status: Exclude; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + if ( + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument") + ) { + return { + status: "warning", + authStatus: "unknown", + message: + "Cursor Agent authentication status command is unavailable in this version of the Agent CLI.", + }; + } + + if ( + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("run `agent login`") || + lowerOutput.includes("run agent login") + ) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }; + } + + const parsedAuth = (() => { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + try { + return { + attemptedJsonParse: true as const, + auth: extractAuthBoolean(JSON.parse(trimmed)), + }; + } catch { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + })(); + + if (parsedAuth.auth === true) { + return { status: "ready", authStatus: "authenticated" }; + } + if (parsedAuth.auth === false) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }; + } + if (parsedAuth.attemptedJsonParse) { + return { + status: "warning", + authStatus: "unknown", + message: + "Could not verify Cursor Agent authentication status from JSON output (missing auth marker).", + }; + } + if (result.code === 0) { + return { status: "ready", authStatus: "authenticated" }; + } + + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Cursor Agent authentication status. ${detail}` + : "Could not verify Cursor Agent authentication status.", + }; +} + +const runCursorCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const cursorSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.cursor), + ); + const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { + shell: process.platform === "win32", + }); + + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( + function* (): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService + > { + const cursorSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.cursor), + ); + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + cursorSettings.customModels, + ); + + if (!cursorSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + authStatus: "unknown", + message: "Cursor is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runCursorCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + authStatus: "unknown", + message: isCommandMissingCause(error) + ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." + : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + authStatus: "unknown", + message: + "Cursor Agent CLI is installed but failed to run. Timed out while running command.", + }, + }); + } + + const version = versionProbe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + const detail = detailFromResult(version); + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "error", + authStatus: "unknown", + message: detail + ? `Cursor Agent CLI is installed but failed to run. ${detail}` + : "Cursor Agent CLI is installed but failed to run.", + }, + }); + } + + const authProbe = yield* runCursorCommand(["login", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: + error instanceof Error + ? `Could not verify Cursor Agent authentication status: ${error.message}.` + : "Could not verify Cursor Agent authentication status.", + }, + }); + } + + if (Option.isNone(authProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: + "Could not verify Cursor Agent authentication status. Timed out while running command.", + }, + }); + } + + const parsed = parseCursorAuthStatusFromOutput(authProbe.success.value); + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: parsed.status, + authStatus: parsed.authStatus, + ...(parsed.message ? { message: parsed.message } : {}), + }, + }); + }, +); + +export const CursorProviderLive = Layer.effect( + CursorProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const checkProvider = checkCursorProviderStatus().pipe( + Effect.provideService(ServerSettingsService, serverSettings), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + return yield* makeManagedServerProvider({ + getSettings: serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.cursor), + Effect.orDie, + ), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.cursor), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + checkProvider, + }); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 1e66ce8ff57..40f6462eaf8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -8,17 +8,21 @@ import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; import { ClaudeProviderLive } from "./ClaudeProvider"; import { CodexProviderLive } from "./CodexProvider"; +import { CursorProviderLive } from "./CursorProvider"; import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; +import type { CursorProviderShape } from "../Services/CursorProvider"; +import { CursorProvider } from "../Services/CursorProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; const loadProviders = ( codexProvider: CodexProviderShape, claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { + cursorProvider: CursorProviderShape, +): Effect.Effect => + Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot, cursorProvider.getSnapshot], { concurrency: "unbounded", }); @@ -32,18 +36,19 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const cursorProvider = yield* CursorProvider; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider), + yield* loadProviders(codexProvider, claudeProvider, cursorProvider), ); const syncProviders = (options?: { readonly publish?: boolean }) => Effect.gen(function* () { const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider); + const providers = yield* loadProviders(codexProvider, claudeProvider, cursorProvider); yield* Ref.set(providersRef, providers); if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { @@ -59,6 +64,9 @@ export const ProviderRegistryLive = Layer.effect( yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( Effect.forkScoped, ); + yield* Stream.runForEach(cursorProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); return { getProviders: syncProviders({ publish: false }).pipe( @@ -74,10 +82,14 @@ export const ProviderRegistryLive = Layer.effect( case "claudeAgent": yield* claudeProvider.refresh; break; + case "cursor": + yield* cursorProvider.refresh; + break; default: - yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { - concurrency: "unbounded", - }); + yield* Effect.all( + [codexProvider.refresh, claudeProvider.refresh, cursorProvider.refresh], + { concurrency: "unbounded" }, + ); break; } return yield* syncProviders(); @@ -90,4 +102,8 @@ export const ProviderRegistryLive = Layer.effect( }, } satisfies ProviderRegistryShape; }), -).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); +).pipe( + Layer.provideMerge(CodexProviderLive), + Layer.provideMerge(ClaudeProviderLive), + Layer.provideMerge(CursorProviderLive), +); diff --git a/apps/server/src/provider/Services/CursorProvider.ts b/apps/server/src/provider/Services/CursorProvider.ts new file mode 100644 index 00000000000..f4b8611bf8c --- /dev/null +++ b/apps/server/src/provider/Services/CursorProvider.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider"; + +export interface CursorProviderShape extends ServerProviderShape {} + +export class CursorProvider extends ServiceMap.Service()( + "t3/provider/Services/CursorProvider", +) {} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index e4901f2b2e7..80e9b07a15c 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -6,7 +6,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; -import { CursorTraitsMenuContent } from "./CursorTraitsPicker"; import { TraitsMenuContent } from "./TraitsPicker"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -109,7 +108,26 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str }, }, ] - : []; + : provider === "cursor" + ? [ + { + slug: "gpt-5.3-codex", + name: "Codex 5.3", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra high" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + ] + : []; const screen = await render( - ) : ( - - ) + } onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} diff --git a/apps/web/src/components/chat/CursorTraitsPicker.tsx b/apps/web/src/components/chat/CursorTraitsPicker.tsx deleted file mode 100644 index fe589049fd8..00000000000 --- a/apps/web/src/components/chat/CursorTraitsPicker.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { - CURSOR_CLAUDE_OPUS_TIER_OPTIONS, - CURSOR_REASONING_OPTIONS, - type CursorReasoningOption, - type ThreadId, -} from "@t3tools/contracts"; -import type { CursorModelOptions } from "@t3tools/contracts"; -import { - cursorFamilySupportsFastWithReasoning, - cursorSelectionToPersistedModelOptions, - getCursorModelCapabilities, - parseCursorModelSelection, -} from "@t3tools/shared/model"; -import { memo, useCallback, useState } from "react"; -import { ChevronDownIcon } from "lucide-react"; -import { Button } from "../ui/button"; -import { - Menu, - MenuGroup, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator as MenuDivider, - MenuTrigger, -} from "../ui/menu"; -import { useComposerDraftStore } from "../../composerDraftStore"; - -const CURSOR_REASONING_LABELS: Record = { - low: "Low", - normal: "Normal", - high: "High", - xhigh: "Extra high", -}; - -export const CursorTraitsMenuContent = memo(function CursorTraitsMenuContentImpl({ - threadId, - model, - cursorModelOptions, -}: { - threadId: ThreadId; - model: string | null | undefined; - cursorModelOptions: CursorModelOptions | null; -}) { - const setModelSelection = useComposerDraftStore((s) => s.setModelSelection); - const setStickyModelSelection = useComposerDraftStore((s) => s.setStickyModelSelection); - const setProviderModelOptions = useComposerDraftStore((s) => s.setProviderModelOptions); - - const selection = parseCursorModelSelection(model, cursorModelOptions); - const capability = getCursorModelCapabilities(selection.family); - - const applyNextSelection = useCallback( - (nextSel: typeof selection) => { - const persisted = cursorSelectionToPersistedModelOptions(nextSel); - const nextModelSelection = { provider: "cursor" as const, model: nextSel.family }; - setModelSelection(threadId, nextModelSelection); - setProviderModelOptions(threadId, "cursor", persisted, { persistSticky: true }); - setStickyModelSelection(nextModelSelection); - }, - [setModelSelection, setProviderModelOptions, setStickyModelSelection, threadId], - ); - - const showFast = - capability.supportsFast && - cursorFamilySupportsFastWithReasoning(selection.family, selection.reasoning); - - if ( - !capability.supportsReasoning && - !showFast && - !capability.supportsThinking && - !capability.supportsClaudeOpusTier - ) { - return null; - } - - return ( - <> - {capability.supportsClaudeOpusTier ? ( - -
- Opus tier -
- { - const nextTier = CURSOR_CLAUDE_OPUS_TIER_OPTIONS.find((t) => t === value); - if (!nextTier) return; - applyNextSelection({ - ...selection, - claudeOpusTier: nextTier, - }); - }} - > - High - Max - -
- ) : null} - {capability.supportsReasoning ? ( - -
- Reasoning -
- { - const nextReasoning = CURSOR_REASONING_OPTIONS.find((o) => o === value); - if (!nextReasoning) return; - applyNextSelection({ - ...selection, - reasoning: nextReasoning, - }); - }} - > - {CURSOR_REASONING_OPTIONS.map((option) => ( - - {CURSOR_REASONING_LABELS[option]} - {option === capability.defaultReasoning ? " (default)" : ""} - - ))} - -
- ) : null} - {showFast ? ( - <> - {capability.supportsReasoning || capability.supportsClaudeOpusTier ? ( - - ) : null} - -
Fast mode
- { - applyNextSelection({ - ...selection, - fast: value === "on", - }); - }} - > - Off - On - -
- - ) : null} - {capability.supportsThinking ? ( - <> - {capability.supportsReasoning || showFast || capability.supportsClaudeOpusTier ? ( - - ) : null} - -
Thinking
- { - applyNextSelection({ - ...selection, - thinking: value === "on", - }); - }} - > - Off - On (default) - -
- - ) : null} - - ); -}); - -export const CursorTraitsPicker = memo(function CursorTraitsPicker({ - threadId, - model, - cursorModelOptions, -}: { - threadId: ThreadId; - model: string | null | undefined; - cursorModelOptions: CursorModelOptions | null; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const selection = parseCursorModelSelection(model, cursorModelOptions); - const capability = getCursorModelCapabilities(selection.family); - - const showFastTrigger = - capability.supportsFast && - cursorFamilySupportsFastWithReasoning(selection.family, selection.reasoning); - - const triggerLabel = [ - capability.supportsClaudeOpusTier - ? selection.claudeOpusTier === "max" - ? "Max" - : "High" - : null, - capability.supportsReasoning ? CURSOR_REASONING_LABELS[selection.reasoning] : null, - showFastTrigger && selection.fast ? "Fast" : null, - capability.supportsThinking ? `Thinking ${selection.thinking ? "on" : "off"}` : null, - ] - .filter(Boolean) - .join(" · "); - - if ( - !capability.supportsReasoning && - !showFastTrigger && - !capability.supportsThinking && - !capability.supportsClaudeOpusTier - ) { - return null; - } - - return ( - { - setIsMenuOpen(open); - }} - > - - } - > - {triggerLabel.length > 0 ? triggerLabel : "Traits"} - - - - - - ); -}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 5fd97b8cde5..29704b02821 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,6 +1,7 @@ import { type ClaudeModelOptions, type CodexModelOptions, + type CursorModelOptions, type ProviderKind, type ProviderModelOptions, type ServerProviderModel, @@ -50,18 +51,24 @@ function getRawEffort( if (provider === "codex") { return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); } + if (provider === "cursor") { + return trimOrNull((modelOptions as CursorModelOptions | undefined)?.reasoning); + } return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); } +function getEffortKey(provider: ProviderKind): string { + if (provider === "codex") return "reasoningEffort"; + if (provider === "cursor") return "reasoning"; + return "effort"; +} + function buildNextOptions( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, patch: Record, ): ProviderOptions { - if (provider === "codex") { - return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions; - } - return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; + return { ...(modelOptions as Record | undefined), ...patch } as ProviderOptions; } function getSelectedTraits( @@ -177,7 +184,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ onPromptChange(nextPrompt); return; } - const effortKey = provider === "codex" ? "reasoningEffort" : "effort"; + const effortKey = getEffortKey(provider); updateModelOptions( buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), ); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 9290f8b0559..d2e1413ec37 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -21,6 +21,47 @@ const CODEX_MODELS: ReadonlyArray = [ }, ]; +const CURSOR_MODELS: ReadonlyArray = [ + { + slug: "auto", + name: "Auto", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "Codex 5.3", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra high" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, +]; + const CLAUDE_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-6", @@ -241,7 +282,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "cursor", model: "auto", - models: [], + models: CURSOR_MODELS, prompt: "", modelOptions: undefined, }); @@ -257,7 +298,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "cursor", model: "composer-2", - models: [], + models: CURSOR_MODELS, prompt: "", modelOptions: { cursor: { fastMode: true }, @@ -271,6 +312,42 @@ describe("getComposerProviderState", () => { }); }); + it("resolves Cursor reasoning effort from server-driven capabilities", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "gpt-5.3-codex", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { reasoning: "high" }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: "high", + modelOptionsForDispatch: { reasoning: "high" }, + }); + }); + + it("drops default Cursor reasoning from dispatch options", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "gpt-5.3-codex", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { reasoning: "normal" }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: "normal", + modelOptionsForDispatch: undefined, + }); + }); + it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => { const state = getComposerProviderState({ provider: "claudeAgent", diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index ee15f390d6b..8fff3711a9c 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -7,20 +7,18 @@ import { } from "@t3tools/contracts"; import { isClaudeUltrathinkPrompt, - normalizeCursorModelOptions, trimOrNull, getDefaultEffort, hasEffortLevel, } from "@t3tools/shared/model"; -import type { CursorModelOptions } from "@t3tools/contracts"; import type { ReactNode } from "react"; import { getProviderModelCapabilities, normalizeClaudeModelOptionsWithCapabilities, normalizeCodexModelOptionsWithCapabilities, + normalizeCursorModelOptionsWithCapabilities, } from "../../providerModels"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; -import { CursorTraitsMenuContent, CursorTraitsPicker } from "./CursorTraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; @@ -72,7 +70,9 @@ function getProviderStateFromCapabilities( ? providerOptions.effort : "reasoningEffort" in providerOptions ? providerOptions.reasoningEffort - : null + : "reasoning" in providerOptions + ? providerOptions.reasoning + : null : null; const draftEffort = trimOrNull(rawEffort); @@ -91,7 +91,9 @@ function getProviderStateFromCapabilities( const normalizedOptions = provider === "codex" ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) - : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); + : provider === "cursor" + ? normalizeCursorModelOptionsWithCapabilities(caps, providerOptions) + : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); // Ultrathink styling (driven by capabilities data, not provider identity) const ultrathinkActive = @@ -175,26 +177,34 @@ const composerProviderRegistry: Record = { ), }, cursor: { - getState: ({ model, modelOptions }) => { - const normalized = normalizeCursorModelOptions(model, modelOptions?.cursor); - return { - provider: "cursor" as const, - promptEffort: null, - modelOptionsForDispatch: normalized ?? undefined, - }; - }, - renderTraitsMenuContent: ({ threadId, model, modelOptions }) => ( - getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( + ), - renderTraitsPicker: ({ threadId, model, modelOptions }) => ( - ( + ), }, diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index a925ed690f7..795804a6a97 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -2,6 +2,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ClaudeModelOptions, type CodexModelOptions, + type CursorModelOptions, type ModelCapabilities, type ProviderKind, type ServerProvider, @@ -90,6 +91,29 @@ export function normalizeCodexModelOptionsWithCapabilities( return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } +export function normalizeCursorModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: CursorModelOptions | null | undefined, +): CursorModelOptions | undefined { + const defaultEffort = getDefaultEffort(caps); + const reasoning = trimOrNull(modelOptions?.reasoning); + const reasoningValue = + reasoning && hasEffortLevel(caps, reasoning) && reasoning !== defaultEffort + ? (reasoning as CursorModelOptions["reasoning"]) + : undefined; + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const claudeOpusTier = modelOptions?.claudeOpusTier ?? undefined; + const nextOptions: CursorModelOptions = { + ...(reasoningValue ? { reasoning: reasoningValue } : {}), + ...(fastMode ? { fastMode: true } : {}), + ...(thinking === false ? { thinking: false } : {}), + ...(claudeOpusTier ? { claudeOpusTier } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + export function normalizeClaudeModelOptionsWithCapabilities( caps: ModelCapabilities, modelOptions: ClaudeModelOptions | null | undefined, From d4561ada9e0ec607d189aaa6f286a961db03262b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 13:30:29 -0700 Subject: [PATCH 03/82] Switch Cursor model selection to in-session via session/set_config_option Instead of restarting the ACP process when the model changes mid-thread, use session/set_config_option to switch models within a live session. Update sessionModelSwitch to "in-session" and add probe tests to verify the real agent supports this method. Made-with: Cursor --- apps/server/scripts/acp-mock-agent.mjs | 36 +++++++ .../cursor-acp-model-selection-probe.ts | 39 ++++---- .../Layers/ProviderCommandReactor.test.ts | 8 +- .../src/provider/Layers/CursorAdapter.test.ts | 23 ++--- .../src/provider/Layers/CursorAdapter.ts | 50 ++++++---- .../provider/acp/CursorAcpCliProbe.test.ts | 95 +++++++++++++++++++ 6 files changed, 199 insertions(+), 52 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.mjs b/apps/server/scripts/acp-mock-agent.mjs index a261ed692da..d7320b929ea 100644 --- a/apps/server/scripts/acp-mock-agent.mjs +++ b/apps/server/scripts/acp-mock-agent.mjs @@ -11,7 +11,27 @@ const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const sessionId = "mock-session-1"; let currentModeId = "ask"; +let currentModelId = "auto"; let nextRequestId = 1; + +function configOptions() { + return [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: [ + { value: "auto", name: "Auto" }, + { value: "composer-2", name: "Composer 2" }, + { value: "composer-2-fast", name: "Composer 2 Fast" }, + { value: "gpt-5.3-codex", name: "Codex 5.3" }, + ], + }, + ]; +} + const availableModes = [ { id: "ask", @@ -127,6 +147,7 @@ rl.on("line", (line) => { result: { sessionId, modes: modeState(), + configOptions: configOptions(), }, }); return; @@ -146,11 +167,26 @@ rl.on("line", (line) => { id, result: { modes: modeState(), + configOptions: configOptions(), }, }); return; } + if (method === "session/set_config_option" && id !== undefined) { + const configId = msg.params?.configId; + const value = msg.params?.value; + if (configId === "model" && typeof value === "string") { + currentModelId = value; + } + send({ + jsonrpc: "2.0", + id, + result: { configOptions: configOptions() }, + }); + return; + } + if (method === "session/prompt" && id !== undefined) { const requestedSessionId = msg.params?.sessionId ?? sessionId; if (emitToolCalls) { diff --git a/apps/server/scripts/cursor-acp-model-selection-probe.ts b/apps/server/scripts/cursor-acp-model-selection-probe.ts index efcc78affbc..992502ae1ec 100644 --- a/apps/server/scripts/cursor-acp-model-selection-probe.ts +++ b/apps/server/scripts/cursor-acp-model-selection-probe.ts @@ -94,9 +94,21 @@ const program = Effect.gen(function* () { yield* adapter.sendTurn({ threadId, - input: "probe model selection", + input: "first turn with initial model", attachments: [], }); + + yield* adapter.sendTurn({ + threadId, + input: "second turn after model switch", + attachments: [], + modelSelection: { + provider: "cursor", + model: "composer-2", + options: { fastMode: true }, + }, + }); + yield* adapter.stopSession(threadId); const argv = (yield* Effect.promise(() => readFile(argvLogPath, "utf8"))) @@ -104,30 +116,23 @@ const program = Effect.gen(function* () { .map((line) => line.trim()) .filter((line) => line.length > 0); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const promptRequest = requests.find((entry) => entry.method === "session/prompt"); - const promptParams = - promptRequest?.params && - typeof promptRequest.params === "object" && - !Array.isArray(promptRequest.params) - ? promptRequest.params - : null; + const setConfigRequests = requests.filter( + (entry) => entry.method === "session/set_config_option", + ); + const promptRequests = requests.filter((entry) => entry.method === "session/prompt"); return { - input: { - model, - fastMode, - }, + input: { model, fastMode }, dispatchedModel, spawnedArgv: argv, acpMethods: requests .map((entry) => entry.method) .filter((method): method is string => typeof method === "string"), - promptParams, - promptCarriesModel: Boolean( - promptParams && Object.prototype.hasOwnProperty.call(promptParams, "model"), - ), + setConfigRequests: setConfigRequests.map((r) => r.params), + promptCount: promptRequests.length, + sessionRestartCount: requests.filter((entry) => entry.method === "session/new").length, conclusion: - "Cursor model selection is decided before ACP initialize via CLI argv. The ACP session/prompt payload does not carry a model field.", + "Model switching uses session/set_config_option (in-session). No session restart needed.", }; }).pipe(Effect.provide(layer)); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d206d30f942..3a173f52054 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -773,10 +773,9 @@ describe("ProviderCommandReactor", () => { }); }); - it("restarts cursor sessions on model changes while preserving resumeCursor", async () => { + it("switches cursor model in-session without restarting", async () => { const harness = await createHarness({ threadModelSelection: { provider: "cursor", model: "composer-2" }, - sessionModelSwitch: "unsupported", }); const now = new Date().toISOString(); @@ -819,12 +818,11 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.sendTurn.mock.calls.length === 2); - expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ modelSelection: { provider: "cursor", model: "composer-2-fast" }, - resumeCursor: { opaque: "resume-1" }, }); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index fc429ecaa02..12fde586042 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -428,11 +428,11 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ), ); - it.effect("restarts ACP with session/load when the Cursor model changes mid-thread", () => + it.effect("switches model in-session via session/set_config_option", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.makeUnsafe("cursor-model-restart"); + const threadId = ThreadId.makeUnsafe("cursor-model-switch"); const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); const requestLogPath = path.join(tempDir, "requests.ndjson"); const argvLogPath = path.join(tempDir, "argv.txt"); @@ -464,19 +464,16 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }); const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); - assert.deepStrictEqual(argvRuns, [ - ["--model", "composer-2", "acp"], - ["--model", "composer-2-fast", "acp"], - ]); + assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); + assert.deepStrictEqual(argvRuns[0], ["--model", "composer-2", "acp"]); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const loadRequests = requests.filter((entry) => entry.method === "session/load"); - assert.lengthOf(loadRequests, 1); - assert.deepStrictEqual(loadRequests[0]?.params, { - sessionId: "mock-session-1", - cwd: process.cwd(), - mcpServers: [], - }); + const setConfigRequests = requests.filter( + (entry) => entry.method === "session/set_config_option", + ); + assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); + const lastSetConfig = setConfigRequests[setConfigRequests.length - 1]; + assert.equal((lastSetConfig?.params as Record)?.value, "composer-2-fast"); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 121a46000e3..5614ca59c64 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -88,6 +88,18 @@ function parseCursorResume(raw: unknown): { sessionId: string } | undefined { return { sessionId: raw.sessionId.trim() }; } +function extractModelConfigId(sessionResponse: unknown): string | undefined { + if (!isRecord(sessionResponse)) return undefined; + const configOptions = sessionResponse.configOptions; + if (!Array.isArray(configOptions)) return undefined; + for (const opt of configOptions) { + if (isRecord(opt) && opt.category === "model" && typeof opt.id === "string") { + return opt.id; + } + } + return undefined; +} + function buildCursorSpawnInput(cwd: string, opts?: CursorSpawnOptions, model?: string | undefined) { const command = opts?.binaryPath?.trim() || "agent"; const hasCustomArgs = opts?.args && opts.args.length > 0; @@ -670,6 +682,8 @@ interface CursorSessionContext { readonly child: ChildProcessWithoutNullStreams; readonly conn: AcpJsonRpcConnection; acpSessionId: string; + /** ACP configId for the model selector (discovered from session/new configOptions). */ + modelConfigId: string | undefined; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -940,6 +954,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { child, conn, acpSessionId: "", + modelConfigId: undefined, notificationFiber: undefined, pendingApprovals: new Map(), pendingUserInputs: new Map(), @@ -1189,6 +1204,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ctx.session = session; ctx.acpSessionId = acpSessionId; + ctx.modelConfigId = extractModelConfigId(sessionSetupResult); ctx.modeState = parseSessionModeState(sessionSetupResult); const handleNotification = (msg: AcpInboundMessage) => @@ -1269,9 +1285,22 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return session; }); + const setSessionModel = (ctx: CursorSessionContext, model: string) => + Effect.gen(function* () { + const configId = ctx.modelConfigId ?? "model"; + yield* ctx.conn + .request("session/set_config_option", { + sessionId: ctx.acpSessionId, + configId, + value: model, + }) + .pipe(Effect.ignore); + ctx.session = { ...ctx.session, model, updatedAt: yield* nowIso }; + }); + const sendTurn: CursorAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - let ctx = yield* requireSession(input.threadId); + const ctx = yield* requireSession(input.threadId); const turnId = TurnId.makeUnsafe(crypto.randomUUID()); const turnModelSelection = input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; @@ -1279,21 +1308,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { turnModelSelection?.model ?? ctx.session.model, turnModelSelection?.options, ); - const activeModel = resolveCursorDispatchModel(ctx.session.model, undefined); - if (model !== activeModel) { - yield* stopSessionInternal(ctx); - yield* startSession({ - threadId: input.threadId, - provider: PROVIDER, - cwd: ctx.session.cwd, - runtimeMode: ctx.session.runtimeMode, - modelSelection: turnModelSelection ?? { provider: PROVIDER, model }, - ...(ctx.session.resumeCursor !== undefined - ? { resumeCursor: ctx.session.resumeCursor } - : {}), - }); - ctx = yield* requireSession(input.threadId); - } + + yield* setSessionModel(ctx, model); ctx.activeTurnId = turnId; ctx.lastPlanFingerprint = undefined; ctx.toolCalls.clear(); @@ -1494,7 +1510,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "unsupported" }, + capabilities: { sessionModelSwitch: "in-session" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index 9114b88ade2..3546ad9ed35 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -9,6 +9,10 @@ import { describe, expect } from "vitest"; import { makeAcpJsonRpcConnection } from "./AcpJsonRpcConnection.ts"; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => { it.effect("initialize and authenticate against real agent acp", () => Effect.gen(function* () { @@ -31,4 +35,95 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", yield* conn.request("authenticate", { methodId: "cursor_login" }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("session/new returns configOptions with a model selector", () => + Effect.gen(function* () { + const conn = yield* makeAcpJsonRpcConnection({ + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }); + + yield* conn.request("initialize", { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }); + yield* conn.request("authenticate", { methodId: "cursor_login" }); + + const result = yield* conn.request("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + + expect(isRecord(result)).toBe(true); + const r = result as Record; + expect(typeof r.sessionId).toBe("string"); + + const configOptions = r.configOptions; + console.log("session/new configOptions:", JSON.stringify(configOptions, null, 2)); + + if (Array.isArray(configOptions)) { + const modelConfig = configOptions.find( + (opt: unknown) => isRecord(opt) && opt.category === "model", + ); + console.log("Model config option:", JSON.stringify(modelConfig, null, 2)); + expect(modelConfig).toBeDefined(); + expect(isRecord(modelConfig) && typeof modelConfig.id === "string").toBe(true); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("session/set_config_option switches the model in-session", () => + Effect.gen(function* () { + const conn = yield* makeAcpJsonRpcConnection({ + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }); + + yield* conn.request("initialize", { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }); + yield* conn.request("authenticate", { methodId: "cursor_login" }); + + const newResult = (yield* conn.request("session/new", { + cwd: process.cwd(), + mcpServers: [], + })) as Record; + const sessionId = newResult.sessionId as string; + + const configOptions = newResult.configOptions; + let modelConfigId = "model"; + if (Array.isArray(configOptions)) { + const modelConfig = configOptions.find( + (opt: unknown) => isRecord(opt) && opt.category === "model", + ); + if (isRecord(modelConfig) && typeof modelConfig.id === "string") { + modelConfigId = modelConfig.id; + } + } + + const setResult = yield* conn.request("session/set_config_option", { + sessionId, + configId: modelConfigId, + value: "composer-2", + }); + + console.log("session/set_config_option result:", JSON.stringify(setResult, null, 2)); + + expect(isRecord(setResult)).toBe(true); + const sr = setResult as Record; + if (Array.isArray(sr.configOptions)) { + const modelConfig = sr.configOptions.find( + (opt: unknown) => isRecord(opt) && opt.category === "model", + ); + if (isRecord(modelConfig)) { + expect(modelConfig.currentValue).toBe("composer-2"); + } + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); From d747322ee52fb5769e384b28e43a0d199abc2b88 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 15:06:15 -0700 Subject: [PATCH 04/82] Refactor Cursor model handling in ChatView and ProviderModelPicker - Removed unused CursorModelOptions and related logic from ChatView. - Updated model selection handling to map concrete Cursor slugs to server-provided options. - Simplified ProviderModelPicker by eliminating unnecessary cursor-related state and logic. - Adjusted tests to reflect changes in model selection behavior for Cursor provider. Made-with: Cursor --- apps/web/src/components/ChatView.tsx | 46 +------------ .../CompactComposerControlsMenu.browser.tsx | 2 +- .../chat/ProviderModelPicker.browser.tsx | 62 ++++++++++++++--- .../components/chat/ProviderModelPicker.tsx | 66 +++++-------------- apps/web/src/routes/_chat.settings.tsx | 1 - packages/contracts/src/model.ts | 27 -------- packages/shared/src/model.test.ts | 16 +++++ packages/shared/src/model.ts | 35 ++++++++-- 8 files changed, 115 insertions(+), 140 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 785e0be42eb..f9ad61f0feb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,7 +2,6 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, - type CursorModelOptions, type MessageId, type ModelSelection, type ProjectScript, @@ -22,14 +21,7 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { - applyClaudePromptEffortPrefix, - getDefaultModel, - isCursorModelFamilySlug, - normalizeModelSlug, - parseCursorModelSelection, - resolveModelSlugForProvider, -} from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -283,9 +275,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); - const setComposerDraftProviderModelOptions = useComposerDraftStore( - (store) => store.setProviderModelOptions, - ); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, @@ -3129,33 +3118,6 @@ export default function ChatView({ threadId }: ChatViewProps) { providerStatuses, model, ); - if ( - resolvedProvider === "cursor" && - isCursorModelFamilySlug(resolvedModel) && - activeThread.id.length > 0 - ) { - const prevDraft = useComposerDraftStore.getState().draftsByThreadId[activeThread.id]; - const prevCursorSelection = prevDraft?.modelSelectionByProvider?.cursor; - const prevModelRaw = - prevCursorSelection?.model ?? - (typeof activeThread.modelSelection?.model === "string" - ? resolveModelSlugForProvider("cursor", activeThread.modelSelection.model) - : null) ?? - getDefaultModel("cursor"); - const prevResolved = resolveAppModelSelection( - "cursor", - settings, - providerStatuses, - prevModelRaw, - ); - const prevCursorOptions = prevCursorSelection?.options as CursorModelOptions | undefined; - const prevFamily = parseCursorModelSelection(prevResolved, prevCursorOptions).family; - if (prevFamily !== resolvedModel) { - setComposerDraftProviderModelOptions(activeThread.id, "cursor", null, { - persistSticky: true, - }); - } - } const nextModelSelection: ModelSelection = { provider: resolvedProvider, model: resolvedModel, @@ -3169,7 +3131,6 @@ export default function ChatView({ threadId }: ChatViewProps) { lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, - setComposerDraftProviderModelOptions, setStickyComposerModelSelection, providerStatuses, settings, @@ -3847,11 +3808,6 @@ export default function ChatView({ threadId }: ChatViewProps) { lockedProvider={lockedProvider} providers={providerStatuses} modelOptionsByProvider={modelOptionsByProvider} - cursorModelOptions={ - selectedProvider === "cursor" - ? (composerModelOptions?.cursor ?? null) - : null - } {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index feeacb032f3..740ae2f5517 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -244,7 +244,7 @@ describe("CompactComposerControlsMenu", () => { it("shows Cursor reasoning controls for GPT-5.3 Codex family", async () => { const mounted = await mountMenu({ - modelSelection: { provider: "cursor", model: "gpt-5.3-codex-high" }, + modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, }); try { diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 3e19b064933..5c0b8d91dcf 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,8 +1,4 @@ -import { - CURSOR_MODEL_FAMILY_OPTIONS, - type ProviderKind, - type ServerProvider, -} from "@t3tools/contracts"; +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -112,6 +108,58 @@ const TEST_PROVIDERS: ReadonlyArray = [ }, ], }, + { + provider: "cursor", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + authStatus: "authenticated", + checkedAt: new Date().toISOString(), + models: [ + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "Codex 5.3", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + effort("low"), + effort("normal", true), + effort("high"), + effort("xhigh"), + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-4.6-opus", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }, ]; async function mountPicker(props: { @@ -132,7 +180,6 @@ async function mountPicker(props: { props.provider, props.model, ), - cursor: [...CURSOR_MODEL_FAMILY_OPTIONS], }; const screen = await render( , @@ -249,7 +295,7 @@ describe("ProviderModelPicker", () => { } }); - it("keeps Cursor submenu values as family keys (traits resolve the CLI slug)", async () => { + it("maps concrete Cursor slugs onto the server-provided model options", async () => { const mounted = await mountPicker({ provider: "cursor", model: "claude-4.6-opus-high-thinking", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 9ab503c3c26..3e0ab11be0b 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,16 +1,5 @@ -import { - CURSOR_MODEL_FAMILY_OPTIONS, - MODEL_OPTIONS_BY_PROVIDER, - type CursorModelOptions, - type ProviderKind, - type ServerProvider, -} from "@t3tools/contracts"; -import { - isCursorModelFamilySlug, - parseCursorModelSelection, - resolveModelSlugForProvider, - resolveSelectableModel, -} from "@t3tools/shared/model"; +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { resolveModelSlugForProvider, resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; @@ -73,55 +62,28 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { triggerVariant?: VariantProps["variant"]; triggerClassName?: string; disabledReason?: string; - cursorModelOptions: CursorModelOptions | null; onProviderModelChange: (provider: ProviderKind, model: string) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; - const cursorFamilyLabel = (() => { - if (activeProvider !== "cursor") return null; - const family = parseCursorModelSelection(props.model, props.cursorModelOptions).family; - const entry = CURSOR_MODEL_FAMILY_OPTIONS.find((o) => o.slug === family); - return entry?.name ?? null; - })(); + const selectedModelValue = + resolveSelectableModel(activeProvider, props.model, selectedProviderOptions) ?? props.model; const selectedModelLabel = - activeProvider === "cursor" - ? (cursorFamilyLabel ?? - MODEL_OPTIONS_BY_PROVIDER.cursor.find((option) => option.slug === props.model)?.name ?? - props.model) - : (selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? - props.model); + selectedProviderOptions.find((option) => option.slug === selectedModelValue)?.name ?? + props.model; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; const handleModelChange = (provider: ProviderKind, value: string) => { if (props.disabled) return; if (!value) return; - let resolvedModel: string | null = null; - if (provider === "cursor") { - if (isCursorModelFamilySlug(value)) { - resolvedModel = value; - } else { - resolvedModel = - resolveSelectableModel(provider, value, props.modelOptionsByProvider[provider]) ?? - resolveModelSlugForProvider(provider, value); - } - } else { - resolvedModel = resolveSelectableModel( - provider, - value, - props.modelOptionsByProvider[provider], - ); - } + const resolvedModel = + resolveSelectableModel(provider, value, props.modelOptionsByProvider[provider]) ?? + resolveModelSlugForProvider(provider, value); if (!resolvedModel) return; props.onProviderModelChange(provider, resolvedModel); setIsMenuOpen(false); }; - const cursorRadioValue = - activeProvider === "cursor" - ? parseCursorModelSelection(props.model, props.cursorModelOptions).family - : ""; - return ( handleModelChange(props.lockedProvider!, value)} > {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( @@ -230,9 +192,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { handleModelChange(option.value, value)} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4f572395eb..26bdeeac4dd 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -852,7 +852,6 @@ function SettingsRouteView() { lockedProvider={null} providers={serverProviders} modelOptionsByProvider={gitModelOptionsByProvider} - cursorModelOptions={null} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onProviderModelChange={(provider, model) => { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 4660ca6799a..b1b36fd2a27 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -77,33 +77,6 @@ export type ModelOption = { readonly name: string; }; -type CursorModelFamilyOption = { - readonly slug: string; - readonly name: string; -}; - -/** - * High-level families shown in the Cursor provider submenu (traits refine the concrete slug). - * Slug ids are aligned with `agent models` where possible; synthetic keys (`gpt-5.4-1m`, `claude-4.6-opus`, - * `claude-4.6-sonnet`) are not standalone CLI models — see `packages/shared` resolvers. - * - * Note: `agent models` had no `premium`, `composer-1`, or Claude Haiku 4.5 ids at snapshot time - * (`packages/contracts/src/cursorCliModels.json`). - */ -export const CURSOR_MODEL_FAMILY_OPTIONS = [ - { slug: "auto", name: "Auto" }, - { slug: "composer-2", name: "Composer 2" }, - { slug: "composer-1.5", name: "Composer 1.5" }, - { slug: "gpt-5.3-codex", name: "Codex 5.3" }, - { slug: "gpt-5.3-codex-spark-preview", name: "Codex 5.3 Spark" }, - { slug: "gpt-5.4-1m", name: "GPT 5.4" }, - { slug: "claude-4.6-opus", name: "Claude Opus 4.6" }, - { slug: "claude-4.6-sonnet", name: "Claude Sonnet 4.6" }, - { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, -] as const satisfies readonly CursorModelFamilyOption[]; - -export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"]; - export const MODEL_OPTIONS_BY_PROVIDER = { codex: [ { slug: "gpt-5.4", name: "GPT-5.4" }, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index e11d42536d3..8b34ec620a2 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -100,6 +100,22 @@ describe("resolveSelectableModel", () => { expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); }); + + it("maps concrete slugs back to the closest selectable family option", () => { + const cursorOptions = [ + { slug: "composer-2", name: "Composer 2" }, + { slug: "gpt-5.3-codex", name: "Codex 5.3" }, + { slug: "claude-4.6-opus", name: "Claude Opus 4.6" }, + ]; + + expect(resolveSelectableModel("cursor", "composer-2-fast", cursorOptions)).toBe("composer-2"); + expect(resolveSelectableModel("cursor", "gpt-5.3-codex-high-fast", cursorOptions)).toBe( + "gpt-5.3-codex", + ); + expect(resolveSelectableModel("cursor", "claude-4.6-opus-high-thinking", cursorOptions)).toBe( + "claude-4.6-opus", + ); + }); }); describe("capability helpers", () => { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 79faa8ecb19..af0dce50308 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,5 +1,4 @@ import { - CURSOR_MODEL_FAMILY_OPTIONS, CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_REASONING_EFFORT_BY_PROVIDER, @@ -11,7 +10,6 @@ import { type CodexModelOptions, type CodexReasoningEffort, type CursorClaudeOpusTier, - type CursorModelFamily, type CursorModelOptions, type CursorModelSlug, type CursorReasoningOption, @@ -28,6 +26,20 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record> = cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)), }; +const CURSOR_MODEL_FAMILY_OPTIONS = [ + { slug: "auto", name: "Auto" }, + { slug: "composer-2", name: "Composer 2" }, + { slug: "composer-1.5", name: "Composer 1.5" }, + { slug: "gpt-5.3-codex", name: "Codex 5.3" }, + { slug: "gpt-5.3-codex-spark-preview", name: "Codex 5.3 Spark" }, + { slug: "gpt-5.4-1m", name: "GPT 5.4" }, + { slug: "claude-4.6-opus", name: "Claude Opus 4.6" }, + { slug: "claude-4.6-sonnet", name: "Claude Sonnet 4.6" }, + { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, +] as const; + +export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"]; + type CursorModelCapability = { readonly supportsReasoning: boolean; readonly supportsFast: boolean; @@ -134,10 +146,6 @@ export interface CursorModelSelection { readonly claudeOpusTier: CursorClaudeOpusTier; } -export function getCursorModelFamilyOptions() { - return CURSOR_MODEL_FAMILY_OPTIONS; -} - export function getCursorModelCapabilities(family: CursorModelFamily) { return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; } @@ -611,7 +619,20 @@ export function resolveSelectableModel( } const resolved = options.find((option) => option.slug === normalized); - return resolved ? resolved.slug : null; + if (resolved) { + return resolved.slug; + } + + const familyMatch = options + .toSorted((left, right) => right.slug.length - left.slug.length) + .find((option) => { + if (!normalized.startsWith(option.slug)) { + return false; + } + const nextChar = normalized.charAt(option.slug.length); + return nextChar === "-" || nextChar === "[" || nextChar === ""; + }); + return familyMatch?.slug ?? null; } export function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string { From 55cef69915939c9d1c2f745b5ad08c9d4025e883 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 15:52:18 -0700 Subject: [PATCH 05/82] rm probes --- apps/server/scripts/acp-mock-agent.mjs | 315 ------------------ .../cursor-acp-model-selection-probe.ts | 140 -------- 2 files changed, 455 deletions(-) delete mode 100644 apps/server/scripts/acp-mock-agent.mjs delete mode 100644 apps/server/scripts/cursor-acp-model-selection-probe.ts diff --git a/apps/server/scripts/acp-mock-agent.mjs b/apps/server/scripts/acp-mock-agent.mjs deleted file mode 100644 index d7320b929ea..00000000000 --- a/apps/server/scripts/acp-mock-agent.mjs +++ /dev/null @@ -1,315 +0,0 @@ -#!/usr/bin/env node -/** - * Minimal NDJSON JSON-RPC "agent" for ACP client tests. - * Reads stdin lines; writes responses/notifications to stdout. - */ -import * as readline from "node:readline"; -import { appendFileSync } from "node:fs"; - -const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); -const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; -const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; -const sessionId = "mock-session-1"; -let currentModeId = "ask"; -let currentModelId = "auto"; -let nextRequestId = 1; - -function configOptions() { - return [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: currentModelId, - options: [ - { value: "auto", name: "Auto" }, - { value: "composer-2", name: "Composer 2" }, - { value: "composer-2-fast", name: "Composer 2 Fast" }, - { value: "gpt-5.3-codex", name: "Codex 5.3" }, - ], - }, - ]; -} - -const availableModes = [ - { - id: "ask", - name: "Ask", - description: "Request permission before making any changes", - }, - { - id: "architect", - name: "Architect", - description: "Design and plan software systems without implementation", - }, - { - id: "code", - name: "Code", - description: "Write and modify code with full tool access", - }, -]; -const pendingPermissionRequests = new Map(); - -function send(obj) { - process.stdout.write(`${JSON.stringify(obj)}\n`); -} - -function modeState() { - return { - currentModeId, - availableModes, - }; -} - -function sendSessionUpdate(update, session = sessionId) { - send({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: session, - update, - }, - }); -} - -rl.on("line", (line) => { - const trimmed = line.trim(); - if (!trimmed) return; - let msg; - try { - msg = JSON.parse(trimmed); - } catch { - return; - } - if (!msg || typeof msg !== "object") return; - if (requestLogPath) { - appendFileSync(requestLogPath, `${JSON.stringify(msg)}\n`, "utf8"); - } - - const id = msg.id; - const method = msg.method; - - if (method === undefined && id !== undefined && pendingPermissionRequests.has(id)) { - const pending = pendingPermissionRequests.get(id); - pendingPermissionRequests.delete(id); - sendSessionUpdate( - { - sessionUpdate: "tool_call_update", - toolCallId: pending.toolCallId, - title: "Terminal", - kind: "execute", - status: "completed", - rawOutput: { - exitCode: 0, - stdout: '{ "name": "t3" }', - stderr: "", - }, - }, - pending.sessionId, - ); - sendSessionUpdate( - { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "hello from mock" }, - }, - pending.sessionId, - ); - send({ - jsonrpc: "2.0", - id: pending.promptRequestId, - result: { stopReason: "end_turn" }, - }); - return; - } - - if (method === "initialize" && id !== undefined) { - send({ - jsonrpc: "2.0", - id, - result: { - protocolVersion: 1, - agentCapabilities: { loadSession: true }, - }, - }); - return; - } - - if (method === "authenticate" && id !== undefined) { - send({ jsonrpc: "2.0", id, result: { authenticated: true } }); - return; - } - - if (method === "session/new" && id !== undefined) { - send({ - jsonrpc: "2.0", - id, - result: { - sessionId, - modes: modeState(), - configOptions: configOptions(), - }, - }); - return; - } - - if (method === "session/load" && id !== undefined) { - const requestedSessionId = msg.params?.sessionId ?? sessionId; - sendSessionUpdate( - { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: "replay" }, - }, - requestedSessionId, - ); - send({ - jsonrpc: "2.0", - id, - result: { - modes: modeState(), - configOptions: configOptions(), - }, - }); - return; - } - - if (method === "session/set_config_option" && id !== undefined) { - const configId = msg.params?.configId; - const value = msg.params?.value; - if (configId === "model" && typeof value === "string") { - currentModelId = value; - } - send({ - jsonrpc: "2.0", - id, - result: { configOptions: configOptions() }, - }); - return; - } - - if (method === "session/prompt" && id !== undefined) { - const requestedSessionId = msg.params?.sessionId ?? sessionId; - if (emitToolCalls) { - const toolCallId = "tool-call-1"; - const permissionRequestId = nextRequestId++; - sendSessionUpdate( - { - sessionUpdate: "tool_call", - toolCallId, - title: "Terminal", - kind: "execute", - status: "pending", - rawInput: { - command: ["cat", "server/package.json"], - }, - }, - requestedSessionId, - ); - sendSessionUpdate( - { - sessionUpdate: "tool_call_update", - toolCallId, - status: "in_progress", - }, - requestedSessionId, - ); - pendingPermissionRequests.set(permissionRequestId, { - promptRequestId: id, - sessionId: requestedSessionId, - toolCallId, - }); - send({ - jsonrpc: "2.0", - id: permissionRequestId, - method: "session/request_permission", - params: { - sessionId: requestedSessionId, - toolCall: { - toolCallId, - title: "`cat server/package.json`", - kind: "execute", - status: "pending", - content: [ - { - type: "content", - content: { - type: "text", - text: "Not in allowlist: cat server/package.json", - }, - }, - ], - }, - options: [ - { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, - { optionId: "allow-always", name: "Allow always", kind: "allow_always" }, - { optionId: "reject-once", name: "Reject", kind: "reject_once" }, - ], - }, - }); - return; - } - sendSessionUpdate( - { - sessionUpdate: "plan", - explanation: `Mock plan while in ${currentModeId}`, - entries: [ - { - content: "Inspect mock ACP state", - priority: "high", - status: "completed", - }, - { - content: "Implement the requested change", - priority: "high", - status: "in_progress", - }, - ], - }, - requestedSessionId, - ); - sendSessionUpdate( - { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "hello from mock" }, - }, - requestedSessionId, - ); - send({ - jsonrpc: "2.0", - id, - result: { stopReason: "end_turn" }, - }); - return; - } - - if ((method === "session/set_mode" || method === "session/mode/set") && id !== undefined) { - const nextModeId = - typeof msg.params?.modeId === "string" - ? msg.params.modeId - : typeof msg.params?.mode === "string" - ? msg.params.mode - : undefined; - if (typeof nextModeId === "string" && nextModeId.trim()) { - currentModeId = nextModeId.trim(); - sendSessionUpdate({ - sessionUpdate: "current_mode_update", - currentModeId, - }); - } - send({ jsonrpc: "2.0", id, result: null }); - return; - } - - if (method === "session/cancel" && id !== undefined) { - send({ jsonrpc: "2.0", id, result: null }); - return; - } - - if (id !== undefined) { - send({ - jsonrpc: "2.0", - id, - error: { code: -32601, message: `Unhandled method: ${String(method)}` }, - }); - } -}); diff --git a/apps/server/scripts/cursor-acp-model-selection-probe.ts b/apps/server/scripts/cursor-acp-model-selection-probe.ts deleted file mode 100644 index 992502ae1ec..00000000000 --- a/apps/server/scripts/cursor-acp-model-selection-probe.ts +++ /dev/null @@ -1,140 +0,0 @@ -import * as os from "node:os"; -import * as path from "node:path"; -import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Layer } from "effect"; - -import { ThreadId } from "@t3tools/contracts"; -import { resolveCursorDispatchModel } from "@t3tools/shared/model"; - -import { ServerConfig } from "../src/config.ts"; -import { ServerSettingsService } from "../src/serverSettings.ts"; -import { CursorAdapter } from "../src/provider/Services/CursorAdapter.ts"; -import { makeCursorAdapterLive } from "../src/provider/Layers/CursorAdapter.ts"; - -const scriptDir = import.meta.dir; -const mockAgentPath = path.join(scriptDir, "acp-mock-agent.mjs"); - -function parseArgs(argv: string[]) { - const args = new Map(); - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (!token?.startsWith("--")) continue; - const key = token.slice(2); - const next = argv[index + 1]; - if (!next || next.startsWith("--")) { - args.set(key, true); - continue; - } - args.set(key, next); - index += 1; - } - return args; -} - -async function makeProbeWrapper(requestLogPath: string, argvLogPath: string) { - const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-script-")); - const wrapperPath = path.join(dir, "fake-agent.sh"); - const script = `#!/bin/sh -printf '%s\n' "$@" > ${JSON.stringify(argvLogPath)} -export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} -exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" -`; - await writeFile(wrapperPath, script, "utf8"); - await chmod(wrapperPath, 0o755); - return wrapperPath; -} - -async function readJsonLines(filePath: string) { - const raw = await readFile(filePath, "utf8"); - return raw - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as Record); -} - -const cliArgs = parseArgs(process.argv.slice(2)); -const model = - typeof cliArgs.get("model") === "string" ? String(cliArgs.get("model")) : "composer-2"; -const fastMode = cliArgs.get("fast") === true; - -const layer = makeCursorAdapterLive().pipe( - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(NodeServices.layer), -); - -const program = Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const serverSettings = yield* ServerSettingsService; - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); - const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath)); - const threadId = ThreadId.makeUnsafe("cursor-acp-model-selection-probe"); - const cursorModelOptions = fastMode ? { fastMode: true as const } : undefined; - const dispatchedModel = resolveCursorDispatchModel(model, cursorModelOptions); - - yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - - yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { - provider: "cursor", - model, - ...(cursorModelOptions ? { options: cursorModelOptions } : {}), - }, - }); - - yield* adapter.sendTurn({ - threadId, - input: "first turn with initial model", - attachments: [], - }); - - yield* adapter.sendTurn({ - threadId, - input: "second turn after model switch", - attachments: [], - modelSelection: { - provider: "cursor", - model: "composer-2", - options: { fastMode: true }, - }, - }); - - yield* adapter.stopSession(threadId); - - const argv = (yield* Effect.promise(() => readFile(argvLogPath, "utf8"))) - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const setConfigRequests = requests.filter( - (entry) => entry.method === "session/set_config_option", - ); - const promptRequests = requests.filter((entry) => entry.method === "session/prompt"); - - return { - input: { model, fastMode }, - dispatchedModel, - spawnedArgv: argv, - acpMethods: requests - .map((entry) => entry.method) - .filter((method): method is string => typeof method === "string"), - setConfigRequests: setConfigRequests.map((r) => r.params), - promptCount: promptRequests.length, - sessionRestartCount: requests.filter((entry) => entry.method === "session/new").length, - conclusion: - "Model switching uses session/set_config_option (in-session). No session restart needed.", - }; -}).pipe(Effect.provide(layer)); - -const result = await Effect.runPromise(program); -process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); From 86e60de8ba6b5c91b589ff76f67a75a069ac1f93 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 15:53:49 -0700 Subject: [PATCH 06/82] rm probe --- scripts/cursor-agent-models-probe.mjs | 115 -------------------------- 1 file changed, 115 deletions(-) delete mode 100644 scripts/cursor-agent-models-probe.mjs diff --git a/scripts/cursor-agent-models-probe.mjs b/scripts/cursor-agent-models-probe.mjs deleted file mode 100644 index bf56ed0b718..00000000000 --- a/scripts/cursor-agent-models-probe.mjs +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env node -/** - * Probes the local Cursor CLI for the authoritative model id list (`agent models`). - * - * Usage: - * node scripts/cursor-agent-models-probe.mjs # print JSON to stdout - * node scripts/cursor-agent-models-probe.mjs --write # write packages/contracts/src/cursorCliModels.json - * node scripts/cursor-agent-models-probe.mjs --check # fail if snapshot is stale vs live CLI - * - * Requires `agent` on PATH (install: Cursor CLI). Uses the same auth as interactive agent. - */ -import { spawnSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = join(__dirname, ".."); -const SNAPSHOT_PATH = join(REPO_ROOT, "packages/contracts/src/cursorCliModels.json"); - -const ESC = "\u001B"; -const ANSI = new RegExp(`${ESC}\\[[0-9;]*[a-zA-Z]`, "g"); - -function stripAnsi(text) { - return text.replace(ANSI, ""); -} - -function cleanDisplayLabel(raw) { - return raw - .replace(/\s*\(default\)\s*$/i, "") - .replace(/\s*\(current\)\s*$/i, "") - .trim(); -} - -function parseModelsOutput(text) { - const lines = stripAnsi(text).split("\n"); - const models = []; - for (const line of lines) { - const trimmed = line.trim(); - const m = /^(\S+)\s+-\s+(.+)$/.exec(trimmed); - if (!m) continue; - const id = m[1]; - const label = cleanDisplayLabel(m[2]); - if (id === "Tip:" || id === "Available") continue; - models.push({ id, label }); - } - return models; -} - -function probeLiveModels() { - const r = spawnSync("agent", ["models"], { - encoding: "utf8", - maxBuffer: 10 * 1024 * 1024, - }); - if (r.error) { - throw r.error; - } - if (r.status !== 0) { - throw new Error(r.stderr || `agent models exited ${r.status}`); - } - return parseModelsOutput(r.stdout ?? ""); -} - -function agentVersion() { - const r = spawnSync("agent", ["-v"], { encoding: "utf8" }); - if (r.status !== 0) return null; - return (r.stdout ?? "").trim() || null; -} - -function main() { - const write = process.argv.includes("--write"); - const check = process.argv.includes("--check"); - - const models = probeLiveModels(); - if (models.length === 0) { - console.error( - "cursor-agent-models-probe: no models parsed (is `agent` installed and logged in?)", - ); - process.exit(1); - } - - const payload = { - probeCommand: "agent models", - generatedAt: new Date().toISOString(), - agentVersion: agentVersion(), - models, - }; - - if (write) { - writeFileSync(SNAPSHOT_PATH, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); - console.error(`Wrote ${models.length} models to ${SNAPSHOT_PATH}`); - } - - if (check) { - const existing = JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8")); - const want = new Set(existing.models.map((m) => m.id)); - const got = new Set(models.map((m) => m.id)); - const missing = [...want].filter((id) => !got.has(id)); - const extra = [...got].filter((id) => !want.has(id)); - if (missing.length || extra.length) { - console.error("cursor-agent-models-probe: snapshot drift vs live `agent models`"); - if (missing.length) console.error("missing from live:", missing.join(", ")); - if (extra.length) console.error("extra in live:", extra.join(", ")); - console.error("Re-run: node scripts/cursor-agent-models-probe.mjs --write"); - process.exit(1); - } - console.error(`OK: ${models.length} models match ${SNAPSHOT_PATH}`); - } - - if (!write && !check) { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } -} - -main(); From e7c5ac311ba61e1e1656869b79b41b559eb4ba0c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 16:23:40 -0700 Subject: [PATCH 07/82] Probe Cursor ACP session setup in one script - Add a standalone ACP probe script for initialize/auth/session/new - Switch Cursor provider status checks to `agent about` for version and auth - Log the ACP session/new result in the probe test --- .../cursor-acp-model-selection-probe.ts | 64 ++++++ .../src/provider/Layers/CursorProvider.ts | 211 +++++++----------- .../provider/acp/CursorAcpCliProbe.test.ts | 1 + 3 files changed, 147 insertions(+), 129 deletions(-) create mode 100644 apps/server/scripts/cursor-acp-model-selection-probe.ts diff --git a/apps/server/scripts/cursor-acp-model-selection-probe.ts b/apps/server/scripts/cursor-acp-model-selection-probe.ts new file mode 100644 index 00000000000..c8fef3c7d07 --- /dev/null +++ b/apps/server/scripts/cursor-acp-model-selection-probe.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env bun +/** + * Standalone probe: spawn `agent acp`, send initialize → session/new, print results. + * + * Usage: + * bun apps/server/scripts/cursor-acp-model-selection-probe.ts [--cwd /path/to/dir] + */ +import { parseArgs } from "node:util"; + +import { + attachAcpJsonRpcConnection, + disposeAcpChild, + spawnAcpChildProcess, +} from "../src/provider/acp/AcpJsonRpcConnection.ts"; +import { Effect } from "effect"; + +const { values } = parseArgs({ + options: { + cwd: { type: "string", default: process.cwd() }, + }, + strict: true, +}); + +const cwd = values.cwd!; + +const program = Effect.gen(function* () { + const child = yield* spawnAcpChildProcess({ command: "agent", args: ["acp"], cwd }); + + try { + const conn = yield* attachAcpJsonRpcConnection(child); + + console.log("→ initialize"); + const initResult = yield* conn.request("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "cursor-acp-probe", version: "0.0.0" }, + }); + console.log("← initialize response:"); + console.log(JSON.stringify(initResult, null, 2)); + + console.log("\n→ authenticate"); + const authResult = yield* conn.request("authenticate", { methodId: "cursor_login" }); + console.log("← authenticate response:"); + console.log(JSON.stringify(authResult, null, 2)); + + console.log(`\n→ session/new (cwd: ${cwd})`); + const sessionResult = yield* conn.request("session/new", { + cwd, + mcpServers: [], + }); + console.log("← session/new response:"); + console.log(JSON.stringify(sessionResult, null, 2)); + } finally { + disposeAcpChild(child); + } +}); + +Effect.runPromise(program).catch((err) => { + console.error("Probe failed:", err); + process.exit(1); +}); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 1a4fee4cb7b..3fc60342a41 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -12,11 +12,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildServerProvider, collectStreamAsString, - DEFAULT_TIMEOUT_MS, - detailFromResult, - extractAuthBoolean, isCommandMissingCause, - parseGenericCliVersion, providerModelsFromSettings, type CommandResult, } from "../providerSnapshot"; @@ -164,85 +160,105 @@ export function getCursorModelCapabilities(model: string | null | undefined): Mo ); } -export function parseCursorAuthStatusFromOutput(result: CommandResult): { +/** Timeout for `agent about` — it's slower than a simple `--version` probe. */ +const ABOUT_TIMEOUT_MS = 8_000; + +/** Strip ANSI escape sequences so we can parse plain key-value lines. */ +function stripAnsi(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07/g, ""); +} + +/** + * Extract a value from `agent about` key-value output. + * Lines look like: `CLI Version 2026.03.20-44cb435` + */ +function extractAboutField(plain: string, key: string): string | undefined { + const regex = new RegExp(`^${key}\\s{2,}(.+)$`, "mi"); + const match = regex.exec(plain); + return match?.[1]?.trim(); +} + +export interface CursorAboutResult { + readonly version: string | null; readonly status: Exclude; readonly authStatus: ServerProviderAuthStatus; readonly message?: string; -} { - const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); +} + +/** + * Parse the output of `agent about` to extract version and authentication + * status in a single probe. + * + * Example output (logged in): + * ``` + * About Cursor CLI + * + * CLI Version 2026.03.20-44cb435 + * User Email user@example.com + * ``` + * + * Example output (logged out): + * ``` + * About Cursor CLI + * + * CLI Version 2026.03.20-44cb435 + * User Email Not logged in + * ``` + */ +export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult { + const combined = `${result.stdout}\n${result.stderr}`; + const lowerOutput = combined.toLowerCase(); + // If the command itself isn't recognised, we're on an old CLI version. if ( lowerOutput.includes("unknown command") || lowerOutput.includes("unrecognized command") || lowerOutput.includes("unexpected argument") ) { return { + version: null, status: "warning", authStatus: "unknown", - message: - "Cursor Agent authentication status command is unavailable in this version of the Agent CLI.", + message: "The `agent about` command is unavailable in this version of the Cursor Agent CLI.", }; } - if ( - lowerOutput.includes("not logged in") || - lowerOutput.includes("login required") || - lowerOutput.includes("authentication required") || - lowerOutput.includes("run `agent login`") || - lowerOutput.includes("run agent login") - ) { + const plain = stripAnsi(combined); + console.log("plain:", plain); + const version = extractAboutField(plain, "CLI Version") ?? null; + const userEmail = extractAboutField(plain, "User Email"); + + // Determine auth from the User Email field. + if (userEmail === undefined) { + // Field missing entirely — can't determine auth. + if (result.code === 0) { + return { version, status: "ready", authStatus: "unknown" }; + } return { - status: "error", - authStatus: "unauthenticated", - message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + version, + status: "warning", + authStatus: "unknown", + message: "Could not verify Cursor Agent authentication status.", }; } - const parsedAuth = (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - })(); - - if (parsedAuth.auth === true) { - return { status: "ready", authStatus: "authenticated" }; - } - if (parsedAuth.auth === false) { + const lowerEmail = userEmail.toLowerCase(); + if ( + lowerEmail === "not logged in" || + lowerEmail.includes("login required") || + lowerEmail.includes("authentication required") + ) { return { + version, status: "error", authStatus: "unauthenticated", message: "Cursor Agent is not authenticated. Run `agent login` and try again.", }; } - if (parsedAuth.attemptedJsonParse) { - return { - status: "warning", - authStatus: "unknown", - message: - "Could not verify Cursor Agent authentication status from JSON output (missing auth marker).", - }; - } - if (result.code === 0) { - return { status: "ready", authStatus: "authenticated" }; - } - const detail = detailFromResult(result); - return { - status: "warning", - authStatus: "unknown", - message: detail - ? `Could not verify Cursor Agent authentication status. ${detail}` - : "Could not verify Cursor Agent authentication status.", - }; + // Any non-empty email value means authenticated. + return { version, status: "ready", authStatus: "authenticated" }; } const runCursorCommand = (args: ReadonlyArray) => @@ -302,13 +318,14 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( }); } - const versionProbe = yield* runCursorCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + // Single `agent about` probe: returns version + auth status in one call. + const aboutProbe = yield* runCursorCommand(["about"]).pipe( + Effect.timeoutOption(ABOUT_TIMEOUT_MS), Effect.result, ); - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; + if (Result.isFailure(aboutProbe)) { + const error = aboutProbe.failure; return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, @@ -326,7 +343,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( }); } - if (Option.isNone(versionProbe.success)) { + if (Option.isNone(aboutProbe.success)) { return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, @@ -337,76 +354,12 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( version: null, status: "error", authStatus: "unknown", - message: - "Cursor Agent CLI is installed but failed to run. Timed out while running command.", - }, - }); - } - - const version = versionProbe.success.value; - const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); - if (version.code !== 0) { - const detail = detailFromResult(version); - return buildServerProvider({ - provider: PROVIDER, - enabled: cursorSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "error", - authStatus: "unknown", - message: detail - ? `Cursor Agent CLI is installed but failed to run. ${detail}` - : "Cursor Agent CLI is installed but failed to run.", - }, - }); - } - - const authProbe = yield* runCursorCommand(["login", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: cursorSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - authStatus: "unknown", - message: - error instanceof Error - ? `Could not verify Cursor Agent authentication status: ${error.message}.` - : "Could not verify Cursor Agent authentication status.", - }, - }); - } - - if (Option.isNone(authProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: cursorSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - authStatus: "unknown", - message: - "Could not verify Cursor Agent authentication status. Timed out while running command.", + message: "Cursor Agent CLI is installed but timed out while running `agent about`.", }, }); } - const parsed = parseCursorAuthStatusFromOutput(authProbe.success.value); + const parsed = parseCursorAboutOutput(aboutProbe.success.value); return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, @@ -414,7 +367,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( models, probe: { installed: true, - version: parsedVersion, + version: parsed.version, status: parsed.status, authStatus: parsed.authStatus, ...(parsed.message ? { message: parsed.message } : {}), diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index 3546ad9ed35..deb86f99c04 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -55,6 +55,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", cwd: process.cwd(), mcpServers: [], }); + console.log("session/new result:", JSON.stringify(result, null, 2)); expect(isRecord(result)).toBe(true); const r = result as Record; From c37322e5660c5f319c049f28e7d1a6aec2d68e4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 16:24:27 -0700 Subject: [PATCH 08/82] rm unused probe --- .../cursor-acp-model-selection-probe.ts | 64 ------------------- 1 file changed, 64 deletions(-) delete mode 100644 apps/server/scripts/cursor-acp-model-selection-probe.ts diff --git a/apps/server/scripts/cursor-acp-model-selection-probe.ts b/apps/server/scripts/cursor-acp-model-selection-probe.ts deleted file mode 100644 index c8fef3c7d07..00000000000 --- a/apps/server/scripts/cursor-acp-model-selection-probe.ts +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bun -/** - * Standalone probe: spawn `agent acp`, send initialize → session/new, print results. - * - * Usage: - * bun apps/server/scripts/cursor-acp-model-selection-probe.ts [--cwd /path/to/dir] - */ -import { parseArgs } from "node:util"; - -import { - attachAcpJsonRpcConnection, - disposeAcpChild, - spawnAcpChildProcess, -} from "../src/provider/acp/AcpJsonRpcConnection.ts"; -import { Effect } from "effect"; - -const { values } = parseArgs({ - options: { - cwd: { type: "string", default: process.cwd() }, - }, - strict: true, -}); - -const cwd = values.cwd!; - -const program = Effect.gen(function* () { - const child = yield* spawnAcpChildProcess({ command: "agent", args: ["acp"], cwd }); - - try { - const conn = yield* attachAcpJsonRpcConnection(child); - - console.log("→ initialize"); - const initResult = yield* conn.request("initialize", { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, - clientInfo: { name: "cursor-acp-probe", version: "0.0.0" }, - }); - console.log("← initialize response:"); - console.log(JSON.stringify(initResult, null, 2)); - - console.log("\n→ authenticate"); - const authResult = yield* conn.request("authenticate", { methodId: "cursor_login" }); - console.log("← authenticate response:"); - console.log(JSON.stringify(authResult, null, 2)); - - console.log(`\n→ session/new (cwd: ${cwd})`); - const sessionResult = yield* conn.request("session/new", { - cwd, - mcpServers: [], - }); - console.log("← session/new response:"); - console.log(JSON.stringify(sessionResult, null, 2)); - } finally { - disposeAcpChild(child); - } -}); - -Effect.runPromise(program).catch((err) => { - console.error("Probe failed:", err); - process.exit(1); -}); From e1f8939c6985217de23c4dc4106e0f217e4f8fbb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 18:04:41 -0700 Subject: [PATCH 09/82] Normalize provider model IDs and Cursor options - Canonicalize Claude and Cursor dispatch model slugs - Update provider model selection, defaults, and tests --- apps/server/scripts/acp-mock-agent.mjs | 315 ++++++++ .../src/git/Layers/ClaudeTextGeneration.ts | 4 +- .../Layers/ProviderCommandReactor.test.ts | 8 +- .../src/provider/Layers/ClaudeAdapter.ts | 12 +- .../src/provider/Layers/ClaudeModelId.test.ts | 28 + .../src/provider/Layers/ClaudeModelId.ts | 10 + .../src/provider/Layers/CursorAdapter.test.ts | 9 +- .../src/provider/Layers/CursorAdapter.ts | 2 +- .../provider/Layers/CursorProvider.test.ts | 47 ++ .../src/provider/Layers/CursorProvider.ts | 101 ++- .../CompactComposerControlsMenu.browser.tsx | 2 +- .../chat/ProviderModelPicker.browser.tsx | 8 +- .../chat/composerProviderRegistry.test.tsx | 6 +- apps/web/src/composerDraftStore.ts | 13 +- apps/web/src/modelSelection.ts | 2 +- apps/web/src/providerModels.ts | 2 - apps/web/src/routes/_chat.settings.tsx | 2 +- packages/contracts/src/cursorCliModels.json | 343 --------- packages/contracts/src/model.ts | 85 +- packages/contracts/src/settings.ts | 2 +- packages/shared/src/model.test.ts | 280 +------ packages/shared/src/model.ts | 726 +----------------- 22 files changed, 539 insertions(+), 1468 deletions(-) create mode 100644 apps/server/scripts/acp-mock-agent.mjs create mode 100644 apps/server/src/provider/Layers/ClaudeModelId.test.ts create mode 100644 apps/server/src/provider/Layers/ClaudeModelId.ts create mode 100644 apps/server/src/provider/Layers/CursorProvider.test.ts delete mode 100644 packages/contracts/src/cursorCliModels.json diff --git a/apps/server/scripts/acp-mock-agent.mjs b/apps/server/scripts/acp-mock-agent.mjs new file mode 100644 index 00000000000..c828cc453d6 --- /dev/null +++ b/apps/server/scripts/acp-mock-agent.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * Minimal NDJSON JSON-RPC "agent" for ACP client tests. + * Reads stdin lines; writes responses/notifications to stdout. + */ +import * as readline from "node:readline"; +import { appendFileSync } from "node:fs"; + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; +const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const sessionId = "mock-session-1"; +let currentModeId = "ask"; +let currentModelId = "default"; +let nextRequestId = 1; + +function configOptions() { + return [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: [ + { value: "default", name: "Auto" }, + { value: "composer-2", name: "Composer 2" }, + { value: "composer-2[fast=true]", name: "Composer 2 Fast" }, + { value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" }, + ], + }, + ]; +} + +const availableModes = [ + { + id: "ask", + name: "Ask", + description: "Request permission before making any changes", + }, + { + id: "architect", + name: "Architect", + description: "Design and plan software systems without implementation", + }, + { + id: "code", + name: "Code", + description: "Write and modify code with full tool access", + }, +]; +const pendingPermissionRequests = new Map(); + +function send(obj) { + process.stdout.write(`${JSON.stringify(obj)}\n`); +} + +function modeState() { + return { + currentModeId, + availableModes, + }; +} + +function sendSessionUpdate(update, session = sessionId) { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: session, + update, + }, + }); +} + +rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch { + return; + } + if (!msg || typeof msg !== "object") return; + if (requestLogPath) { + appendFileSync(requestLogPath, `${JSON.stringify(msg)}\n`, "utf8"); + } + + const id = msg.id; + const method = msg.method; + + if (method === undefined && id !== undefined && pendingPermissionRequests.has(id)) { + const pending = pendingPermissionRequests.get(id); + pendingPermissionRequests.delete(id); + sendSessionUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId: pending.toolCallId, + title: "Terminal", + kind: "execute", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: '{ "name": "t3" }', + stderr: "", + }, + }, + pending.sessionId, + ); + sendSessionUpdate( + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from mock" }, + }, + pending.sessionId, + ); + send({ + jsonrpc: "2.0", + id: pending.promptRequestId, + result: { stopReason: "end_turn" }, + }); + return; + } + + if (method === "initialize" && id !== undefined) { + send({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + }, + }); + return; + } + + if (method === "authenticate" && id !== undefined) { + send({ jsonrpc: "2.0", id, result: { authenticated: true } }); + return; + } + + if (method === "session/new" && id !== undefined) { + send({ + jsonrpc: "2.0", + id, + result: { + sessionId, + modes: modeState(), + configOptions: configOptions(), + }, + }); + return; + } + + if (method === "session/load" && id !== undefined) { + const requestedSessionId = msg.params?.sessionId ?? sessionId; + sendSessionUpdate( + { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "replay" }, + }, + requestedSessionId, + ); + send({ + jsonrpc: "2.0", + id, + result: { + modes: modeState(), + configOptions: configOptions(), + }, + }); + return; + } + + if (method === "session/set_config_option" && id !== undefined) { + const configId = msg.params?.configId; + const value = msg.params?.value; + if (configId === "model" && typeof value === "string") { + currentModelId = value; + } + send({ + jsonrpc: "2.0", + id, + result: { configOptions: configOptions() }, + }); + return; + } + + if (method === "session/prompt" && id !== undefined) { + const requestedSessionId = msg.params?.sessionId ?? sessionId; + if (emitToolCalls) { + const toolCallId = "tool-call-1"; + const permissionRequestId = nextRequestId++; + sendSessionUpdate( + { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + command: ["cat", "server/package.json"], + }, + }, + requestedSessionId, + ); + sendSessionUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + }, + requestedSessionId, + ); + pendingPermissionRequests.set(permissionRequestId, { + promptRequestId: id, + sessionId: requestedSessionId, + toolCallId, + }); + send({ + jsonrpc: "2.0", + id: permissionRequestId, + method: "session/request_permission", + params: { + sessionId: requestedSessionId, + toolCall: { + toolCallId, + title: "`cat server/package.json`", + kind: "execute", + status: "pending", + content: [ + { + type: "content", + content: { + type: "text", + text: "Not in allowlist: cat server/package.json", + }, + }, + ], + }, + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "allow-always", name: "Allow always", kind: "allow_always" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }, + }); + return; + } + sendSessionUpdate( + { + sessionUpdate: "plan", + explanation: `Mock plan while in ${currentModeId}`, + entries: [ + { + content: "Inspect mock ACP state", + priority: "high", + status: "completed", + }, + { + content: "Implement the requested change", + priority: "high", + status: "in_progress", + }, + ], + }, + requestedSessionId, + ); + sendSessionUpdate( + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from mock" }, + }, + requestedSessionId, + ); + send({ + jsonrpc: "2.0", + id, + result: { stopReason: "end_turn" }, + }); + return; + } + + if ((method === "session/set_mode" || method === "session/mode/set") && id !== undefined) { + const nextModeId = + typeof msg.params?.modeId === "string" + ? msg.params.modeId + : typeof msg.params?.mode === "string" + ? msg.params.mode + : undefined; + if (typeof nextModeId === "string" && nextModeId.trim()) { + currentModeId = nextModeId.trim(); + sendSessionUpdate({ + sessionUpdate: "current_mode_update", + currentModeId, + }); + } + send({ jsonrpc: "2.0", id, result: null }); + return; + } + + if (method === "session/cancel" && id !== undefined) { + send({ jsonrpc: "2.0", id, result: null }); + return; + } + + if (id !== undefined) { + send({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Unhandled method: ${String(method)}` }, + }); + } +}); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 919c3a323dd..2b9fc26e51e 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -11,7 +11,6 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeModelSelection } from "@t3tools/contracts"; -import { resolveApiModelId } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "../Errors.ts"; @@ -27,6 +26,7 @@ import { sanitizePrTitle, toJsonSchemaObject, } from "../Utils.ts"; +import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeModelId.ts"; import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -104,7 +104,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { "--json-schema", jsonSchemaStr, "--model", - resolveApiModelId(modelSelection), + resolveClaudeApiModelId(modelSelection), ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 3a173f52054..b85899c4261 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -811,7 +811,11 @@ describe("ProviderCommandReactor", () => { text: "second cursor turn", attachments: [], }, - modelSelection: { provider: "cursor", model: "composer-2-fast" }, + modelSelection: { + provider: "cursor", + model: "composer-2", + options: { fastMode: true }, + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -822,7 +826,7 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls.length).toBe(1); expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - modelSelection: { provider: "cursor", model: "composer-2-fast" }, + modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, }); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index e7602ea5c4c..3fda9626877 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -40,12 +40,7 @@ import { type UserInputQuestion, ClaudeCodeEffort, } from "@t3tools/contracts"; -import { - applyClaudePromptEffortPrefix, - resolveApiModelId, - resolveEffort, - trimOrNull, -} from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@t3tools/shared/model"; import { Cause, DateTime, @@ -65,6 +60,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; +import { resolveClaudeApiModelId } from "./ClaudeModelId.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -2732,7 +2728,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); - const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; + const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? null) as ClaudeCodeEffort | null; const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; @@ -2897,7 +2893,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { } if (modelSelection?.model) { - const apiModelId = resolveApiModelId(modelSelection); + const apiModelId = resolveClaudeApiModelId(modelSelection); yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), diff --git a/apps/server/src/provider/Layers/ClaudeModelId.test.ts b/apps/server/src/provider/Layers/ClaudeModelId.test.ts new file mode 100644 index 00000000000..5102047fc7d --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeModelId.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { resolveClaudeApiModelId } from "./ClaudeModelId.ts"; + +describe("resolveClaudeApiModelId", () => { + it("appends [1m] for 1m context window", () => { + expect( + resolveClaudeApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "1m" }, + }), + ).toBe("claude-opus-4-6[1m]"); + }); + + it("returns the canonical slug for default context windows", () => { + expect( + resolveClaudeApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "200k" }, + }), + ).toBe("claude-opus-4-6"); + expect(resolveClaudeApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( + "claude-opus-4-6", + ); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeModelId.ts b/apps/server/src/provider/Layers/ClaudeModelId.ts new file mode 100644 index 00000000000..a4371c80a51 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeModelId.ts @@ -0,0 +1,10 @@ +import type { ClaudeModelSelection } from "@t3tools/contracts"; + +export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { + switch (modelSelection.options?.contextWindow) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; + } +} diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 12fde586042..b97872b0336 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -8,12 +8,12 @@ import { assert, it } from "@effect/vitest"; import { Deferred, Effect, Fiber, Layer, Stream } from "effect"; import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; -import { resolveCursorDispatchModel } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { makeCursorAdapterLive } from "./CursorAdapter.ts"; +import { resolveCursorDispatchModel } from "./CursorProvider.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); @@ -465,7 +465,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); - assert.deepStrictEqual(argvRuns[0], ["--model", "composer-2", "acp"]); + assert.deepStrictEqual(argvRuns[0], ["--model", "composer-2[fast=false]", "acp"]); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const setConfigRequests = requests.filter( @@ -473,7 +473,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ); assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); const lastSetConfig = setConfigRequests[setConfigRequests.length - 1]; - assert.equal((lastSetConfig?.params as Record)?.value, "composer-2-fast"); + assert.equal( + (lastSetConfig?.params as Record)?.value, + "composer-2[fast=true]", + ); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 5614ca59c64..28c78e4b688 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -22,7 +22,6 @@ import { TurnId, type UserInputQuestion, } from "@t3tools/contracts"; -import { resolveCursorDispatchModel } from "@t3tools/shared/model"; import { Cause, DateTime, @@ -57,6 +56,7 @@ import { import type { AcpInboundMessage } from "../acp/AcpTypes.ts"; import { AcpProcessExitedError, AcpRpcError, type AcpError } from "../acp/AcpErrors.ts"; import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { resolveCursorDispatchModel } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "cursor" as const; diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts new file mode 100644 index 00000000000..3f5094b82ed --- /dev/null +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { getCursorModelCapabilities, resolveCursorDispatchModel } from "./CursorProvider.ts"; + +describe("resolveCursorDispatchModel", () => { + it("builds bracket notation from canonical base slugs and capabilities", () => { + expect(resolveCursorDispatchModel("composer-2", { fastMode: true })).toBe( + "composer-2[fast=true]", + ); + expect(resolveCursorDispatchModel("gpt-5.4", undefined)).toBe( + "gpt-5.4[reasoning=medium,context=272k,fast=false]", + ); + expect( + resolveCursorDispatchModel("claude-opus-4-6", { + reasoning: "high", + thinking: true, + contextWindow: "1m", + }), + ).toBe("claude-opus-4-6[effort=high,thinking=true,context=1m]"); + }); + + it("maps legacy cursor aliases onto the canonical base slug", () => { + expect(resolveCursorDispatchModel("gpt-5.4-1m", undefined)).toBe( + "gpt-5.4[reasoning=medium,context=272k,fast=false]", + ); + expect(resolveCursorDispatchModel("auto", undefined)).toBe("default[]"); + expect(resolveCursorDispatchModel("claude-4.6-opus", undefined)).toBe( + "claude-opus-4-6[effort=high,thinking=true,context=200k]", + ); + }); + + it("passes custom models through unchanged", () => { + expect(resolveCursorDispatchModel("custom/internal-model", undefined)).toBe( + "custom/internal-model[]", + ); + }); +}); + +describe("getCursorModelCapabilities", () => { + it("resolves capabilities from canonical cursor base slugs", () => { + expect(getCursorModelCapabilities("gpt-5.4").contextWindowOptions).toEqual([ + { value: "272k", label: "272k", isDefault: true }, + { value: "1m", label: "1M" }, + ]); + expect(getCursorModelCapabilities("claude-opus-4-6").supportsThinkingToggle).toBe(true); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 3fc60342a41..62a06c0c94e 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1,4 +1,5 @@ import type { + CursorModelOptions, CursorSettings, ModelCapabilities, ServerProvider, @@ -6,6 +7,7 @@ import type { ServerProviderAuthStatus, ServerProviderState, } from "@t3tools/contracts"; +import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -21,9 +23,16 @@ import { CursorProvider } from "../Services/CursorProvider"; import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; const PROVIDER = "cursor" as const; +const EMPTY_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; const BUILT_IN_MODELS: ReadonlyArray = [ { - slug: "auto", + slug: "default", name: "Auto", isCustom: false, capabilities: { @@ -65,7 +74,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ capabilities: { reasoningEffortLevels: [ { value: "low", label: "Low" }, - { value: "normal", label: "Normal", isDefault: true }, + { value: "medium", label: "Medium", isDefault: true }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra High" }, ], @@ -76,13 +85,13 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, }, { - slug: "gpt-5.3-codex-spark-preview", + slug: "gpt-5.3-codex-spark", name: "Codex 5.3 Spark", isCustom: false, capabilities: { reasoningEffortLevels: [ { value: "low", label: "Low" }, - { value: "normal", label: "Normal", isDefault: true }, + { value: "medium", label: "Medium", isDefault: true }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra High" }, ], @@ -93,40 +102,54 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, }, { - slug: "gpt-5.4-1m", - name: "GPT 5.4", + slug: "gpt-5.4", + name: "GPT-5.4", isCustom: false, capabilities: { reasoningEffortLevels: [ { value: "low", label: "Low" }, - { value: "normal", label: "Normal", isDefault: true }, + { value: "medium", label: "Medium", isDefault: true }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra High" }, ], supportsFastMode: true, supportsThinkingToggle: false, - contextWindowOptions: [], + contextWindowOptions: [ + { value: "272k", label: "272k", isDefault: true }, + { value: "1m", label: "1M" }, + ], promptInjectedEffortLevels: [], }, }, { - slug: "claude-4.6-opus", - name: "Claude Opus 4.6", + slug: "claude-opus-4-6", + name: "Opus 4.6", isCustom: false, capabilities: { - reasoningEffortLevels: [], + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + ], supportsFastMode: false, supportsThinkingToggle: true, - contextWindowOptions: [], + contextWindowOptions: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], promptInjectedEffortLevels: [], }, }, { - slug: "claude-4.6-sonnet", - name: "Claude Sonnet 4.6", + slug: "claude-sonnet-4-6", + name: "Sonnet 4.6", isCustom: false, capabilities: { - reasoningEffortLevels: [], + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + ], supportsFastMode: false, supportsThinkingToggle: true, contextWindowOptions: [], @@ -148,18 +171,49 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ]; export function getCursorModelCapabilities(model: string | null | undefined): ModelCapabilities { - const slug = model?.trim(); + const slug = normalizeModelSlug(model, "cursor"); return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES ); } +export function resolveCursorDispatchModel( + model: string | null | undefined, + modelOptions: CursorModelOptions | null | undefined, +): string { + const slug = normalizeModelSlug(model, "cursor") ?? "default"; + if (slug.includes("[") && slug.endsWith("]")) { + return slug; + } + const caps = getCursorModelCapabilities(slug); + const isBuiltIn = BUILT_IN_MODELS.some((candidate) => candidate.slug === slug); + if (!isBuiltIn) { + return `${slug}[]`; + } + + const traits: string[] = []; + const reasoning = resolveEffort(caps, modelOptions?.reasoning); + if (reasoning) { + traits.push(`${slug.startsWith("claude-") ? "effort" : "reasoning"}=${reasoning}`); + } + + const thinking = caps.supportsThinkingToggle ? (modelOptions?.thinking ?? true) : undefined; + if (thinking !== undefined) { + traits.push(`thinking=${thinking}`); + } + + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); + if (contextWindow) { + traits.push(`context=${contextWindow}`); + } + + if (caps.supportsFastMode) { + traits.push(`fast=${modelOptions?.fastMode === true}`); + } + + return `${slug}[${traits.join(",")}]`; +} + /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ const ABOUT_TIMEOUT_MS = 8_000; @@ -225,7 +279,6 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult } const plain = stripAnsi(combined); - console.log("plain:", plain); const version = extractAboutField(plain, "CLI Version") ?? null; const userEmail = extractAboutField(plain, "User Email"); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 740ae2f5517..8275f4c6f5b 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -121,7 +121,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str capabilities: { reasoningEffortLevels: [ { value: "low", label: "Low" }, - { value: "normal", label: "Normal", isDefault: true }, + { value: "medium", label: "Medium", isDefault: true }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra high" }, ], diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 5c0b8d91dcf..9b4dd24e95e 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -136,7 +136,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ capabilities: { reasoningEffortLevels: [ effort("low"), - effort("normal", true), + effort("medium", true), effort("high"), effort("xhigh"), ], @@ -147,7 +147,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ }, }, { - slug: "claude-4.6-opus", + slug: "claude-opus-4-6", name: "Claude Opus 4.6", isCustom: false, capabilities: { @@ -295,10 +295,10 @@ describe("ProviderModelPicker", () => { } }); - it("maps concrete Cursor slugs onto the server-provided model options", async () => { + it("uses canonical Cursor slugs from the server-provided model options", async () => { const mounted = await mountPicker({ provider: "cursor", - model: "claude-4.6-opus-high-thinking", + model: "claude-opus-4-6", lockedProvider: "cursor", }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 4de3ce9b5ca..9ee8714adc0 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -54,7 +54,7 @@ const CURSOR_MODELS: ReadonlyArray = [ capabilities: { reasoningEffortLevels: [ { value: "low", label: "Low" }, - { value: "normal", label: "Normal", isDefault: true }, + { value: "medium", label: "Medium", isDefault: true }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra high" }, ], @@ -352,13 +352,13 @@ describe("getComposerProviderState", () => { models: CURSOR_MODELS, prompt: "", modelOptions: { - cursor: { reasoning: "normal" }, + cursor: { reasoning: "medium" }, }, }); expect(state).toEqual({ provider: "cursor", - promptEffort: "normal", + promptEffort: "medium", modelOptionsForDispatch: undefined, }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index bbf98c1426e..b8f28f2ceec 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2,7 +2,6 @@ import { CODEX_REASONING_EFFORT_OPTIONS, CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, - DEFAULT_REASONING_EFFORT_BY_PROVIDER, type ClaudeCodeEffort, type CodexReasoningEffort, type CursorModelOptions, @@ -508,23 +507,13 @@ function normalizeProviderModelOptions( : undefined; const cursorFastMode = cursorCandidate?.fastMode === true; const cursorThinkingFalse = cursorCandidate?.thinking === false; - const cursorClaudeOpusTierRaw = cursorCandidate?.claudeOpusTier; - const cursorClaudeOpusTier = - cursorClaudeOpusTierRaw === "max" || cursorClaudeOpusTierRaw === "high" - ? cursorClaudeOpusTierRaw - : undefined; - const defaultCursorReasoning = - DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor as CursorReasoningOption; const cursor: CursorModelOptions | undefined = cursorCandidate !== null ? { - ...(cursorReasoning && cursorReasoning !== defaultCursorReasoning - ? { reasoning: cursorReasoning } - : {}), + ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), ...(cursorFastMode ? { fastMode: true } : {}), ...(cursorThinkingFalse ? { thinking: false } : {}), - ...(cursorClaudeOpusTier ? { claudeOpusTier: cursorClaudeOpusTier } : {}), } : undefined; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index f7b6e627363..a2b0fb59129 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -50,7 +50,7 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record 0 ? nextOptions : undefined; } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 26bdeeac4dd..19cd4c9c6bb 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1257,7 +1257,7 @@ function SettingsRouteView() { providerCard.provider === "codex" ? "gpt-6.7-codex-ultra-preview" : providerCard.provider === "cursor" - ? "claude-4.6-sonnet-medium-thinking" + ? "claude-sonnet-4-6" : "claude-sonnet-5-0" } spellCheck={false} diff --git a/packages/contracts/src/cursorCliModels.json b/packages/contracts/src/cursorCliModels.json deleted file mode 100644 index a4a07e7d338..00000000000 --- a/packages/contracts/src/cursorCliModels.json +++ /dev/null @@ -1,343 +0,0 @@ -{ - "probeCommand": "agent models", - "generatedAt": "2026-03-24T01:37:58.372Z", - "agentVersion": "2026.02.27-e7d2ef6", - "models": [ - { - "id": "auto", - "label": "Auto" - }, - { - "id": "composer-2-fast", - "label": "Composer 2 Fast" - }, - { - "id": "composer-2", - "label": "Composer 2" - }, - { - "id": "composer-1.5", - "label": "Composer 1.5" - }, - { - "id": "gpt-5.3-codex-low", - "label": "GPT-5.3 Codex Low" - }, - { - "id": "gpt-5.3-codex-low-fast", - "label": "GPT-5.3 Codex Low Fast" - }, - { - "id": "gpt-5.3-codex", - "label": "GPT-5.3 Codex" - }, - { - "id": "gpt-5.3-codex-fast", - "label": "GPT-5.3 Codex Fast" - }, - { - "id": "gpt-5.3-codex-high", - "label": "GPT-5.3 Codex High" - }, - { - "id": "gpt-5.3-codex-high-fast", - "label": "GPT-5.3 Codex High Fast" - }, - { - "id": "gpt-5.3-codex-xhigh", - "label": "GPT-5.3 Codex Extra High" - }, - { - "id": "gpt-5.3-codex-xhigh-fast", - "label": "GPT-5.3 Codex Extra High Fast" - }, - { - "id": "gpt-5.2", - "label": "GPT-5.2" - }, - { - "id": "gpt-5.3-codex-spark-preview-low", - "label": "GPT-5.3 Codex Spark Low" - }, - { - "id": "gpt-5.3-codex-spark-preview", - "label": "GPT-5.3 Codex Spark" - }, - { - "id": "gpt-5.3-codex-spark-preview-high", - "label": "GPT-5.3 Codex Spark High" - }, - { - "id": "gpt-5.3-codex-spark-preview-xhigh", - "label": "GPT-5.3 Codex Spark Extra High" - }, - { - "id": "gpt-5.2-codex-low", - "label": "GPT-5.2 Codex Low" - }, - { - "id": "gpt-5.2-codex-low-fast", - "label": "GPT-5.2 Codex Low Fast" - }, - { - "id": "gpt-5.2-codex", - "label": "GPT-5.2 Codex" - }, - { - "id": "gpt-5.2-codex-fast", - "label": "GPT-5.2 Codex Fast" - }, - { - "id": "gpt-5.2-codex-high", - "label": "GPT-5.2 Codex High" - }, - { - "id": "gpt-5.2-codex-high-fast", - "label": "GPT-5.2 Codex High Fast" - }, - { - "id": "gpt-5.2-codex-xhigh", - "label": "GPT-5.2 Codex Extra High" - }, - { - "id": "gpt-5.2-codex-xhigh-fast", - "label": "GPT-5.2 Codex Extra High Fast" - }, - { - "id": "gpt-5.1-codex-max-low", - "label": "GPT-5.1 Codex Max Low" - }, - { - "id": "gpt-5.1-codex-max-low-fast", - "label": "GPT-5.1 Codex Max Low Fast" - }, - { - "id": "gpt-5.1-codex-max-medium", - "label": "GPT-5.1 Codex Max" - }, - { - "id": "gpt-5.1-codex-max-medium-fast", - "label": "GPT-5.1 Codex Max Medium Fast" - }, - { - "id": "gpt-5.1-codex-max-high", - "label": "GPT-5.1 Codex Max High" - }, - { - "id": "gpt-5.1-codex-max-high-fast", - "label": "GPT-5.1 Codex Max High Fast" - }, - { - "id": "gpt-5.1-codex-max-xhigh", - "label": "GPT-5.1 Codex Max Extra High" - }, - { - "id": "gpt-5.1-codex-max-xhigh-fast", - "label": "GPT-5.1 Codex Max Extra High Fast" - }, - { - "id": "gpt-5.4-high", - "label": "GPT-5.4 1M High" - }, - { - "id": "gpt-5.4-high-fast", - "label": "GPT-5.4 High Fast" - }, - { - "id": "gpt-5.4-xhigh-fast", - "label": "GPT-5.4 Extra High Fast" - }, - { - "id": "claude-4.6-opus-high-thinking", - "label": "Opus 4.6 1M Thinking" - }, - { - "id": "gpt-5.4-low", - "label": "GPT-5.4 1M Low" - }, - { - "id": "gpt-5.4-medium", - "label": "GPT-5.4 1M" - }, - { - "id": "gpt-5.4-medium-fast", - "label": "GPT-5.4 Fast" - }, - { - "id": "gpt-5.4-xhigh", - "label": "GPT-5.4 1M Extra High" - }, - { - "id": "claude-4.6-sonnet-medium", - "label": "Sonnet 4.6 1M" - }, - { - "id": "claude-4.6-sonnet-medium-thinking", - "label": "Sonnet 4.6 1M Thinking" - }, - { - "id": "claude-4.6-opus-high", - "label": "Opus 4.6 1M" - }, - { - "id": "claude-4.6-opus-max", - "label": "Opus 4.6 1M Max" - }, - { - "id": "claude-4.6-opus-max-thinking", - "label": "Opus 4.6 1M Max Thinking" - }, - { - "id": "claude-4.5-opus-high", - "label": "Opus 4.5" - }, - { - "id": "claude-4.5-opus-high-thinking", - "label": "Opus 4.5 Thinking" - }, - { - "id": "gpt-5.2-low", - "label": "GPT-5.2 Low" - }, - { - "id": "gpt-5.2-low-fast", - "label": "GPT-5.2 Low Fast" - }, - { - "id": "gpt-5.2-fast", - "label": "GPT-5.2 Fast" - }, - { - "id": "gpt-5.2-high", - "label": "GPT-5.2 High" - }, - { - "id": "gpt-5.2-high-fast", - "label": "GPT-5.2 High Fast" - }, - { - "id": "gpt-5.2-xhigh", - "label": "GPT-5.2 Extra High" - }, - { - "id": "gpt-5.2-xhigh-fast", - "label": "GPT-5.2 Extra High Fast" - }, - { - "id": "gemini-3.1-pro", - "label": "Gemini 3.1 Pro" - }, - { - "id": "gpt-5.4-mini-none", - "label": "GPT-5.4 Mini None" - }, - { - "id": "gpt-5.4-mini-low", - "label": "GPT-5.4 Mini Low" - }, - { - "id": "gpt-5.4-mini-medium", - "label": "GPT-5.4 Mini" - }, - { - "id": "gpt-5.4-mini-high", - "label": "GPT-5.4 Mini High" - }, - { - "id": "gpt-5.4-mini-xhigh", - "label": "GPT-5.4 Mini Extra High" - }, - { - "id": "gpt-5.4-nano-none", - "label": "GPT-5.4 Nano None" - }, - { - "id": "gpt-5.4-nano-low", - "label": "GPT-5.4 Nano Low" - }, - { - "id": "gpt-5.4-nano-medium", - "label": "GPT-5.4 Nano" - }, - { - "id": "gpt-5.4-nano-high", - "label": "GPT-5.4 Nano High" - }, - { - "id": "gpt-5.4-nano-xhigh", - "label": "GPT-5.4 Nano Extra High" - }, - { - "id": "grok-4-20", - "label": "Grok 4.20" - }, - { - "id": "grok-4-20-thinking", - "label": "Grok 4.20 Thinking" - }, - { - "id": "claude-4.5-sonnet", - "label": "Sonnet 4.5 1M" - }, - { - "id": "claude-4.5-sonnet-thinking", - "label": "Sonnet 4.5 1M Thinking" - }, - { - "id": "gpt-5.1-low", - "label": "GPT-5.1 Low" - }, - { - "id": "gpt-5.1", - "label": "GPT-5.1" - }, - { - "id": "gpt-5.1-high", - "label": "GPT-5.1 High" - }, - { - "id": "gemini-3-pro", - "label": "Gemini 3 Pro" - }, - { - "id": "gemini-3-flash", - "label": "Gemini 3 Flash" - }, - { - "id": "gpt-5.1-codex-mini-low", - "label": "GPT-5.1 Codex Mini Low" - }, - { - "id": "gpt-5.1-codex-mini", - "label": "GPT-5.1 Codex Mini" - }, - { - "id": "gpt-5.1-codex-mini-high", - "label": "GPT-5.1 Codex Mini High" - }, - { - "id": "claude-4-sonnet", - "label": "Sonnet 4" - }, - { - "id": "claude-4-sonnet-1m", - "label": "Sonnet 4 1M" - }, - { - "id": "claude-4-sonnet-thinking", - "label": "Sonnet 4 Thinking" - }, - { - "id": "claude-4-sonnet-1m-thinking", - "label": "Sonnet 4 1M Thinking" - }, - { - "id": "gpt-5-mini", - "label": "GPT-5 Mini" - }, - { - "id": "kimi-k2.5", - "label": "Kimi K2.5" - } - ] -} diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index b1b36fd2a27..52c174498a5 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,15 +1,12 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; import type { ProviderKind } from "./orchestration"; -import cursorCliModels from "./cursorCliModels.json" with { type: "json" }; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max", "ultrathink"] as const; export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; - -/** Cursor “reasoning” tier for GPT‑5.3 Codex–style families (encoded in model slug). */ -export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const; +export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "xhigh"] as const; export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number]; export type ProviderReasoningEffort = @@ -31,14 +28,11 @@ export const ClaudeModelOptions = Schema.Struct({ }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; -export const CURSOR_CLAUDE_OPUS_TIER_OPTIONS = ["high", "max"] as const; -export type CursorClaudeOpusTier = (typeof CURSOR_CLAUDE_OPUS_TIER_OPTIONS)[number]; - export const CursorModelOptions = Schema.Struct({ reasoning: Schema.optional(Schema.Literals(CURSOR_REASONING_OPTIONS)), fastMode: Schema.optional(Schema.Boolean), thinking: Schema.optional(Schema.Boolean), - claudeOpusTier: Schema.optional(Schema.Literals(CURSOR_CLAUDE_OPUS_TIER_OPTIONS)), + contextWindow: Schema.optional(Schema.String), }); export type CursorModelOptions = typeof CursorModelOptions.Type; @@ -72,41 +66,10 @@ export const ModelCapabilities = Schema.Struct({ }); export type ModelCapabilities = typeof ModelCapabilities.Type; -export type ModelOption = { - readonly slug: string; - readonly name: string; -}; - -export const MODEL_OPTIONS_BY_PROVIDER = { - codex: [ - { slug: "gpt-5.4", name: "GPT-5.4" }, - { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, - { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, - { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, - { slug: "gpt-5.2", name: "GPT-5.2" }, - ], - claudeAgent: [ - { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, - ], - cursor: cursorCliModels.models.map((m) => ({ - slug: m.id, - name: m.label, - })) satisfies ReadonlyArray, -} as const satisfies Record; -export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; - -export type ModelSlug = string & {}; - -/** Any built-in id returned by the Cursor CLI for `--model` (see `cursorCliModels.json`). */ -export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"]; - -export const DEFAULT_MODEL_BY_PROVIDER: Record = { +export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", - cursor: "claude-4.6-opus-high-thinking", + cursor: "auto", }; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; @@ -115,7 +78,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4-mini", claudeAgent: "claude-haiku-4-5", - cursor: "composer-2-fast", + cursor: "composer-2", }; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { @@ -141,23 +104,23 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { claudeAgent: "Claude", cursor: "Cursor", }; - -export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { - codex: CODEX_REASONING_EFFORT_OPTIONS, - claudeAgent: CLAUDE_CODE_EFFORT_OPTIONS, - cursor: CURSOR_REASONING_OPTIONS, -} as const satisfies Record; - -export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { - codex: "high", - claudeAgent: "high", - cursor: "normal", -} as const satisfies Record; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a9705ffea9f..24fa14a5848 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -127,7 +127,7 @@ const CursorModelOptionsPatch = Schema.Struct({ reasoning: Schema.optionalKey(CursorModelOptions.fields.reasoning), fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode), thinking: Schema.optionalKey(CursorModelOptions.fields.thinking), - claudeOpusTier: Schema.optionalKey(CursorModelOptions.fields.claudeOpusTier), + contextWindow: Schema.optionalKey(CursorModelOptions.fields.contextWindow), }); const ModelSelectionPatch = Schema.Union([ diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8b34ec620a2..0bb651ab062 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,38 +1,18 @@ import { describe, expect, it } from "vitest"; -import { - DEFAULT_MODEL_BY_PROVIDER, - DEFAULT_REASONING_EFFORT_BY_PROVIDER, - type ModelCapabilities, -} from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, getDefaultContextWindow, getDefaultEffort, - getDefaultReasoningEffort, - getEffectiveClaudeCodeEffort, hasContextWindowOption, hasEffortLevel, - inferProviderForModel, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptions, - normalizeCodexModelOptions, normalizeModelSlug, - parseCursorModelSelection, - resolveApiModelId, resolveContextWindow, - resolveCursorDispatchModel, - resolveCursorModelFromSelection, resolveEffort, - resolveModelSlug, resolveModelSlugForProvider, - resolveReasoningEffortForProvider, resolveSelectableModel, - supportsClaudeAdaptiveReasoning, - supportsClaudeFastMode, - supportsClaudeMaxEffort, - supportsClaudeThinkingToggle, - supportsClaudeUltrathinkKeyword, trimOrNull, } from "./model"; @@ -76,17 +56,18 @@ describe("normalizeModelSlug", () => { }); }); -describe("resolveModelSlug", () => { +describe("resolveModelSlugForProvider", () => { it("returns defaults when the model is missing", () => { - expect(resolveModelSlug(undefined, "codex")).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); - + expect(resolveModelSlugForProvider("codex", undefined)).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( DEFAULT_MODEL_BY_PROVIDER.claudeAgent, ); }); it("preserves normalized unknown models", () => { - expect(resolveModelSlug("custom/internal-model", "codex")).toBe("custom/internal-model"); + expect(resolveModelSlugForProvider("codex", "custom/internal-model")).toBe( + "custom/internal-model", + ); }); }); @@ -100,22 +81,6 @@ describe("resolveSelectableModel", () => { expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); }); - - it("maps concrete slugs back to the closest selectable family option", () => { - const cursorOptions = [ - { slug: "composer-2", name: "Composer 2" }, - { slug: "gpt-5.3-codex", name: "Codex 5.3" }, - { slug: "claude-4.6-opus", name: "Claude Opus 4.6" }, - ]; - - expect(resolveSelectableModel("cursor", "composer-2-fast", cursorOptions)).toBe("composer-2"); - expect(resolveSelectableModel("cursor", "gpt-5.3-codex-high-fast", cursorOptions)).toBe( - "gpt-5.3-codex", - ); - expect(resolveSelectableModel("cursor", "claude-4.6-opus-high-thinking", cursorOptions)).toBe( - "claude-4.6-opus", - ); - }); }); describe("capability helpers", () => { @@ -130,204 +95,6 @@ describe("capability helpers", () => { }); }); -describe("inferProviderForModel", () => { - it("detects known provider model slugs", () => { - expect(inferProviderForModel("gpt-5.3-codex")).toBe("codex"); - expect(inferProviderForModel("claude-sonnet-4-6")).toBe("claudeAgent"); - expect(inferProviderForModel("sonnet")).toBe("claudeAgent"); - }); - - it("falls back when the model is unknown", () => { - expect(inferProviderForModel("custom/internal-model")).toBe("codex"); - expect(inferProviderForModel("custom/internal-model", "claudeAgent")).toBe("claudeAgent"); - }); - - it("treats claude-prefixed custom slugs as claude", () => { - expect(inferProviderForModel("claude-custom-internal")).toBe("claudeAgent"); - }); - - it("infers cursor from Cursor-only slugs", () => { - expect(inferProviderForModel("claude-4.6-opus-high-thinking")).toBe("cursor"); - expect(inferProviderForModel("composer-1.5")).toBe("cursor"); - }); - - it("infers cursor from family slugs", () => { - expect(inferProviderForModel("composer-2")).toBe("cursor"); - expect(inferProviderForModel("gpt-5.4-1m")).toBe("cursor"); - expect(inferProviderForModel("claude-4.6-opus")).toBe("cursor"); - expect(inferProviderForModel("claude-4.6-sonnet")).toBe("cursor"); - expect(inferProviderForModel("auto")).toBe("cursor"); - }); -}); - -describe("cursor model selection helpers", () => { - it("parses GPT-5.3 Codex reasoning and fast suffixes from slugs", () => { - expect(parseCursorModelSelection("gpt-5.3-codex-high-fast")).toMatchObject({ - family: "gpt-5.3-codex", - reasoning: "high", - fast: true, - thinking: false, - }); - }); - - it("merges persisted cursor modelOptions over the family model key", () => { - expect(parseCursorModelSelection("composer-2", { fastMode: true })).toMatchObject({ - family: "composer-2", - fast: true, - }); - expect(resolveCursorDispatchModel("composer-2", { fastMode: true })).toBe("composer-2-fast"); - expect(resolveCursorDispatchModel("composer-2", undefined)).toBe("composer-2"); - }); - - it("parses and resolves Claude Opus 4.6 tiers and thinking from CLI slugs", () => { - expect(parseCursorModelSelection("claude-4.6-opus-high-thinking")).toMatchObject({ - family: "claude-4.6-opus", - thinking: true, - claudeOpusTier: "high", - }); - expect(parseCursorModelSelection("claude-4.6-opus-max")).toMatchObject({ - claudeOpusTier: "max", - thinking: false, - }); - expect( - resolveCursorModelFromSelection({ - family: "claude-4.6-opus", - thinking: true, - claudeOpusTier: "high", - }), - ).toBe("claude-4.6-opus-high-thinking"); - expect( - resolveCursorModelFromSelection({ - family: "claude-4.6-opus", - thinking: false, - claudeOpusTier: "max", - }), - ).toBe("claude-4.6-opus-max"); - }); -}); - -describe("getDefaultReasoningEffort", () => { - it("returns provider-scoped defaults", () => { - expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex); - expect(getDefaultReasoningEffort("claudeAgent")).toBe( - DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent, - ); - expect(getDefaultReasoningEffort("cursor")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor); - }); -}); - -describe("resolveReasoningEffortForProvider", () => { - it("accepts provider-scoped effort values", () => { - expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh"); - expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink"); - }); - - it("rejects effort values from the wrong provider", () => { - expect(resolveReasoningEffortForProvider("codex", "max")).toBeNull(); - expect(resolveReasoningEffortForProvider("claudeAgent", "xhigh")).toBeNull(); - }); - - it("accepts cursor reasoning tiers", () => { - expect(resolveReasoningEffortForProvider("cursor", "normal")).toBe("normal"); - expect(resolveReasoningEffortForProvider("cursor", "xhigh")).toBe("xhigh"); - }); -}); - -describe("getEffectiveClaudeCodeEffort", () => { - it("does not persist ultrathink into Claude runtime configuration", () => { - expect(getEffectiveClaudeCodeEffort("ultrathink")).toBeNull(); - expect(getEffectiveClaudeCodeEffort("high")).toBe("high"); - }); - - it("returns null when no claude effort is selected", () => { - expect(getEffectiveClaudeCodeEffort(null)).toBeNull(); - expect(getEffectiveClaudeCodeEffort(undefined)).toBeNull(); - }); -}); - -describe("normalizeCodexModelOptions", () => { - it("drops default-only codex options", () => { - expect( - normalizeCodexModelOptions({ reasoningEffort: "high", fastMode: false }), - ).toBeUndefined(); - }); - - it("preserves non-default codex options", () => { - expect(normalizeCodexModelOptions({ reasoningEffort: "xhigh", fastMode: true })).toEqual({ - reasoningEffort: "xhigh", - fastMode: true, - }); - }); -}); - -describe("normalizeClaudeModelOptions", () => { - it("drops unsupported fast mode and max effort for Sonnet", () => { - expect( - normalizeClaudeModelOptions("claude-sonnet-4-6", { - effort: "max", - fastMode: true, - }), - ).toBeUndefined(); - }); - - it("keeps the Haiku thinking toggle and removes unsupported effort", () => { - expect( - normalizeClaudeModelOptions("claude-haiku-4-5", { - thinking: false, - effort: "high", - }), - ).toEqual({ - thinking: false, - }); - }); -}); - -describe("supportsClaudeAdaptiveReasoning", () => { - it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { - expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true); - expect(supportsClaudeAdaptiveReasoning("claude-sonnet-4-6")).toBe(true); - expect(supportsClaudeAdaptiveReasoning("claude-haiku-4-5")).toBe(false); - expect(supportsClaudeAdaptiveReasoning(undefined)).toBe(false); - }); -}); - -describe("supportsClaudeMaxEffort", () => { - it("only enables max effort for Opus 4.6", () => { - expect(supportsClaudeMaxEffort("claude-opus-4-6")).toBe(true); - expect(supportsClaudeMaxEffort("claude-sonnet-4-6")).toBe(false); - expect(supportsClaudeMaxEffort("claude-haiku-4-5")).toBe(false); - expect(supportsClaudeMaxEffort(undefined)).toBe(false); - }); -}); - -describe("supportsClaudeFastMode", () => { - it("only enables Claude fast mode for Opus 4.6", () => { - expect(supportsClaudeFastMode("claude-opus-4-6")).toBe(true); - expect(supportsClaudeFastMode("opus")).toBe(true); - expect(supportsClaudeFastMode("claude-sonnet-4-6")).toBe(false); - expect(supportsClaudeFastMode("claude-haiku-4-5")).toBe(false); - expect(supportsClaudeFastMode(undefined)).toBe(false); - }); -}); - -describe("supportsClaudeUltrathinkKeyword", () => { - it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { - expect(supportsClaudeUltrathinkKeyword("claude-opus-4-6")).toBe(true); - expect(supportsClaudeUltrathinkKeyword("claude-sonnet-4-6")).toBe(true); - expect(supportsClaudeUltrathinkKeyword("claude-haiku-4-5")).toBe(false); - }); -}); - -describe("supportsClaudeThinkingToggle", () => { - it("only enables the Claude thinking toggle for Haiku 4.5", () => { - expect(supportsClaudeThinkingToggle("claude-opus-4-6")).toBe(false); - expect(supportsClaudeThinkingToggle("claude-sonnet-4-6")).toBe(false); - expect(supportsClaudeThinkingToggle("claude-haiku-4-5")).toBe(true); - expect(supportsClaudeThinkingToggle("haiku")).toBe(true); - expect(supportsClaudeThinkingToggle(undefined)).toBe(false); - }); -}); - describe("resolveEffort", () => { it("returns the explicit value when supported and not prompt-injected", () => { expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh"); @@ -424,38 +191,3 @@ describe("resolveContextWindow", () => { expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined(); }); }); - -describe("resolveApiModelId", () => { - it("appends [1m] suffix for 1m context window", () => { - expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "1m" }, - }), - ).toBe("claude-opus-4-6[1m]"); - }); - - it("returns the model as-is for 200k context window", () => { - expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "200k" }, - }), - ).toBe("claude-opus-4-6"); - }); - - it("returns the model as-is when no context window is set", () => { - expect(resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( - "claude-opus-4-6", - ); - expect( - resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6", options: {} }), - ).toBe("claude-opus-4-6"); - }); - - it("returns the model as-is for Codex selections", () => { - expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4"); - }); -}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index af0dce50308..6f5a070ae59 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,501 +1,16 @@ import { - CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, - DEFAULT_REASONING_EFFORT_BY_PROVIDER, - MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - REASONING_EFFORT_OPTIONS_BY_PROVIDER, type ClaudeCodeEffort, - type ClaudeModelOptions, - type CodexModelOptions, - type CodexReasoningEffort, - type CursorClaudeOpusTier, - type CursorModelOptions, - type CursorModelSlug, - type CursorReasoningOption, type ModelCapabilities, - type ModelSlug, - type ModelSelection, type ProviderKind, - type ProviderReasoningEffort, } from "@t3tools/contracts"; -const MODEL_SLUG_SET_BY_PROVIDER: Record> = { - claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), - codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), - cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)), -}; - -const CURSOR_MODEL_FAMILY_OPTIONS = [ - { slug: "auto", name: "Auto" }, - { slug: "composer-2", name: "Composer 2" }, - { slug: "composer-1.5", name: "Composer 1.5" }, - { slug: "gpt-5.3-codex", name: "Codex 5.3" }, - { slug: "gpt-5.3-codex-spark-preview", name: "Codex 5.3 Spark" }, - { slug: "gpt-5.4-1m", name: "GPT 5.4" }, - { slug: "claude-4.6-opus", name: "Claude Opus 4.6" }, - { slug: "claude-4.6-sonnet", name: "Claude Sonnet 4.6" }, - { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, -] as const; - -export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"]; - -type CursorModelCapability = { - readonly supportsReasoning: boolean; - readonly supportsFast: boolean; - readonly supportsThinking: boolean; - readonly supportsClaudeOpusTier: boolean; - readonly defaultReasoning: CursorReasoningOption; - readonly defaultThinking: boolean; - readonly defaultClaudeOpusTier: CursorClaudeOpusTier; -}; - -const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record = { - auto: { - supportsReasoning: false, - supportsFast: false, - supportsThinking: false, - supportsClaudeOpusTier: false, - defaultReasoning: "normal", - defaultThinking: false, - defaultClaudeOpusTier: "high", - }, - "composer-2": { - supportsReasoning: false, - supportsFast: true, - supportsThinking: false, - supportsClaudeOpusTier: false, - defaultReasoning: "normal", - defaultThinking: false, - defaultClaudeOpusTier: "high", - }, - "composer-1.5": { - supportsReasoning: false, - supportsFast: false, - supportsThinking: false, - supportsClaudeOpusTier: false, - defaultReasoning: "normal", - defaultThinking: false, - defaultClaudeOpusTier: "high", - }, - "gpt-5.3-codex": { - supportsReasoning: true, - supportsFast: true, - supportsThinking: false, - supportsClaudeOpusTier: false, - defaultReasoning: "normal", - defaultThinking: false, - defaultClaudeOpusTier: "high", - }, - "gpt-5.3-codex-spark-preview": { - supportsReasoning: true, - supportsFast: false, - supportsThinking: false, - supportsClaudeOpusTier: false, - defaultReasoning: "normal", - defaultThinking: false, - defaultClaudeOpusTier: "high", - }, - "gpt-5.4-1m": { - supportsReasoning: true, - supportsFast: true, - supportsThinking: false, - supportsClaudeOpusTier: false, - defaultReasoning: "normal", - defaultThinking: false, - defaultClaudeOpusTier: "high", - }, - "claude-4.6-opus": { - supportsReasoning: false, - supportsFast: false, - supportsThinking: true, - supportsClaudeOpusTier: true, - defaultReasoning: "normal", - defaultThinking: true, - defaultClaudeOpusTier: "high", - }, - "claude-4.6-sonnet": { - supportsReasoning: false, - supportsFast: false, - supportsThinking: true, - supportsClaudeOpusTier: false, - defaultReasoning: "normal", - defaultThinking: false, - defaultClaudeOpusTier: "high", - }, - "gemini-3.1-pro": { - supportsReasoning: false, - supportsFast: false, - supportsThinking: false, - supportsClaudeOpusTier: false, - defaultReasoning: "normal", - defaultThinking: false, - defaultClaudeOpusTier: "high", - }, -}; - -const CURSOR_MODEL_FAMILY_SET = new Set( - CURSOR_MODEL_FAMILY_OPTIONS.map((option) => option.slug), -); - -export interface CursorModelSelection { - readonly family: CursorModelFamily; - readonly reasoning: CursorReasoningOption; - readonly fast: boolean; - readonly thinking: boolean; - readonly claudeOpusTier: CursorClaudeOpusTier; -} - -export function getCursorModelCapabilities(family: CursorModelFamily) { - return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; -} - -/** Fast toggles are absent for some GPT‑5.4 1M + reasoning combinations in the live CLI model list. */ -export function cursorFamilySupportsFastWithReasoning( - family: CursorModelFamily, - reasoning: CursorReasoningOption, -): boolean { - if (!getCursorModelCapabilities(family).supportsFast) return false; - if (family === "gpt-5.4-1m" && reasoning === "low") return false; - return true; -} - -function fallbackCursorModelFamily(): CursorModelFamily { - return parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor).family; -} - -function resolveCursorModelFamily(model: string | null | undefined): CursorModelFamily { - const normalized = normalizeModelSlug(model, "cursor"); - if (!normalized) { - return fallbackCursorModelFamily(); - } - - if (normalized === "auto") { - return "auto"; - } - - if (normalized === "composer-2" || normalized === "composer-2-fast") { - return "composer-2"; - } - - if (normalized === "composer-1.5") { - return "composer-1.5"; - } - - if (normalized.startsWith("gpt-5.3-codex-spark-preview")) { - return "gpt-5.3-codex-spark-preview"; - } - - if (normalized.startsWith("gpt-5.3-codex")) { - return "gpt-5.3-codex"; - } - - if ( - normalized === "gpt-5.4-low" || - normalized === "gpt-5.4-medium" || - normalized === "gpt-5.4-medium-fast" || - normalized === "gpt-5.4-high" || - normalized === "gpt-5.4-high-fast" || - normalized === "gpt-5.4-xhigh" || - normalized === "gpt-5.4-xhigh-fast" - ) { - return "gpt-5.4-1m"; - } - - if (normalized.startsWith("claude-4.6-opus-")) { - return "claude-4.6-opus"; - } - - if (normalized.startsWith("claude-4.6-sonnet-")) { - return "claude-4.6-sonnet"; - } - - if (normalized === "gemini-3.1-pro") { - return "gemini-3.1-pro"; - } - - return CURSOR_MODEL_FAMILY_SET.has(normalized as CursorModelFamily) - ? (normalized as CursorModelFamily) - : fallbackCursorModelFamily(); -} - -function resolveCursorReasoningFromSlug(model: CursorModelSlug): CursorReasoningOption { - if (model.includes("-xhigh")) return "xhigh"; - if (model.includes("-high")) return "high"; - if (model.includes("-low")) return "low"; - return "normal"; -} - -function parseClaudeOpusFromSlug(slug: string): { - readonly tier: CursorClaudeOpusTier; - readonly thinking: boolean; -} { - return { - tier: slug.includes("opus-max") ? "max" : "high", - thinking: slug.endsWith("-thinking"), - }; -} - -function mergePersistedCursorOptionsOntoSelection( - sel: CursorModelSelection, - cursorOpts: CursorModelOptions | null | undefined, -): CursorModelSelection { - if (!cursorOpts) return sel; - let next: CursorModelSelection = sel; - if ( - typeof cursorOpts.reasoning === "string" && - (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorOpts.reasoning) - ) { - next = { ...next, reasoning: cursorOpts.reasoning }; - } - if (cursorOpts.fastMode === true) { - next = { ...next, fast: true }; - } - if (cursorOpts.fastMode === false) { - next = { ...next, fast: false }; - } - if (cursorOpts.thinking === true) { - next = { ...next, thinking: true }; - } - if (cursorOpts.thinking === false) { - next = { ...next, thinking: false }; - } - if (cursorOpts.claudeOpusTier === "max" || cursorOpts.claudeOpusTier === "high") { - next = { ...next, claudeOpusTier: cursorOpts.claudeOpusTier }; - } - return next; -} - -function parseCursorModelSelectionFromSlugOnly( - model: string | null | undefined, -): CursorModelSelection { - const family = resolveCursorModelFamily(model); - const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; - const normalized = resolveModelSlugForProvider("cursor", model) as CursorModelSlug; - - const base: Pick = { - reasoning: capability.defaultReasoning, - fast: false, - thinking: capability.defaultThinking, - claudeOpusTier: capability.defaultClaudeOpusTier, - }; - - if (capability.supportsReasoning) { - return { - family, - ...base, - reasoning: resolveCursorReasoningFromSlug(normalized), - fast: normalized.endsWith("-fast"), - thinking: false, - claudeOpusTier: "high", - }; - } - - if (family === "claude-4.6-opus") { - const parsed = parseClaudeOpusFromSlug(normalized); - return { - family, - ...base, - reasoning: capability.defaultReasoning, - fast: false, - claudeOpusTier: parsed.tier, - thinking: parsed.thinking, - }; - } - - if (family === "composer-2") { - return { - family, - ...base, - fast: normalized === "composer-2-fast", - thinking: false, - claudeOpusTier: "high", - }; - } - - if (capability.supportsThinking) { - return { - family, - ...base, - reasoning: capability.defaultReasoning, - fast: false, - thinking: normalized.includes("-thinking"), - claudeOpusTier: "high", - }; - } - - return { family, ...base }; -} - -export function parseCursorModelSelection( - model: string | null | undefined, - cursorOpts?: CursorModelOptions | null, -): CursorModelSelection { - return mergePersistedCursorOptionsOntoSelection( - parseCursorModelSelectionFromSlugOnly(model), - cursorOpts, - ); -} - -/** Minimal `cursor` modelOptions for API dispatch (non-default traits only). */ -export function normalizeCursorModelOptions( - model: string | null | undefined, - persisted: CursorModelOptions | null | undefined, -): CursorModelOptions | undefined { - const sel = parseCursorModelSelection(model, persisted); - const cap = getCursorModelCapabilities(sel.family); - const defaultReasoning = DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor as CursorReasoningOption; - const next: { - reasoning?: CursorReasoningOption; - fastMode?: boolean; - thinking?: boolean; - claudeOpusTier?: CursorClaudeOpusTier; - } = {}; - if (cap.supportsReasoning && sel.reasoning !== defaultReasoning) { - next.reasoning = sel.reasoning; - } - if (cap.supportsFast && sel.fast) { - next.fastMode = true; - } - if (cap.supportsThinking && sel.thinking === false) { - next.thinking = false; - } - if (cap.supportsClaudeOpusTier && sel.claudeOpusTier === "max") { - next.claudeOpusTier = "max"; - } - return Object.keys(next).length > 0 ? (next as CursorModelOptions) : undefined; -} - -/** Persisted options for a trait selection (null = all defaults / omit from draft). */ -export function cursorSelectionToPersistedModelOptions( - sel: CursorModelSelection, -): CursorModelOptions | null { - const cap = getCursorModelCapabilities(sel.family); - const defaultReasoning = DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor as CursorReasoningOption; - const next: { - reasoning?: CursorReasoningOption; - fastMode?: boolean; - thinking?: boolean; - claudeOpusTier?: CursorClaudeOpusTier; - } = {}; - if (cap.supportsReasoning && sel.reasoning !== defaultReasoning) { - next.reasoning = sel.reasoning; - } - if (cap.supportsFast && sel.fast) { - next.fastMode = true; - } - if (cap.supportsThinking && sel.thinking === false) { - next.thinking = false; - } - if (cap.supportsClaudeOpusTier && sel.claudeOpusTier === "max") { - next.claudeOpusTier = "max"; - } - return Object.keys(next).length > 0 ? (next as CursorModelOptions) : null; -} - -/** - * Resolves the concrete Cursor CLI `--model` id from the logical family key (or custom slug) plus - * optional persisted `modelOptions.cursor` traits. - */ -export function resolveCursorDispatchModel( - model: string | null | undefined, - cursorOpts: CursorModelOptions | null | undefined, -): string { - const normalized = normalizeModelSlug(model, "cursor") ?? DEFAULT_MODEL_BY_PROVIDER.cursor; - const hasPersistedTraits = Boolean(cursorOpts && Object.keys(cursorOpts).length > 0); - if (hasPersistedTraits && isCursorModelFamilySlug(normalized)) { - const sel = parseCursorModelSelection(normalized, cursorOpts); - return resolveCursorModelFromSelection(sel); - } - return resolveModelSlugForProvider("cursor", normalized); -} - -export function resolveCursorModelFromSelection(input: { - readonly family: CursorModelFamily; - readonly reasoning?: CursorReasoningOption | null; - readonly fast?: boolean | null; - readonly thinking?: boolean | null; - readonly claudeOpusTier?: CursorClaudeOpusTier | null; -}): CursorModelSlug { - const family = resolveCursorModelFamily(input.family); - const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; - - if (family === "composer-2") { - const slug = input.fast === true ? "composer-2-fast" : "composer-2"; - return resolveModelSlugForProvider("cursor", slug) as CursorModelSlug; - } - - if (family === "gpt-5.4-1m") { - const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") - ? (input.reasoning ?? "normal") - : capability.defaultReasoning; - const tier = reasoning === "normal" ? "medium" : reasoning; - const base = `gpt-5.4-${tier}`; - if (input.fast === true) { - const fastSlug = `${base}-fast`; - const candidate = MODEL_SLUG_SET_BY_PROVIDER.cursor.has(fastSlug) ? fastSlug : base; - return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; - } - return resolveModelSlugForProvider("cursor", base) as CursorModelSlug; - } - - if (family === "gpt-5.3-codex-spark-preview") { - const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") - ? (input.reasoning ?? "normal") - : capability.defaultReasoning; - const suffix = reasoning === "normal" ? "" : `-${reasoning}`; - const candidate = `gpt-5.3-codex-spark-preview${suffix}`; - return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; - } - - if (capability.supportsReasoning) { - const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") - ? (input.reasoning ?? "normal") - : capability.defaultReasoning; - const reasoningSuffix = reasoning === "normal" ? "" : `-${reasoning}`; - const fastSuffix = input.fast === true ? "-fast" : ""; - const candidate = `${family}${reasoningSuffix}${fastSuffix}`; - return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; - } - - if (family === "claude-4.6-opus") { - const tier = input.claudeOpusTier === "max" ? "max" : "high"; - const thinking = - input.thinking === false - ? false - : input.thinking === true - ? true - : capability.defaultThinking; - const base = `claude-4.6-opus-${tier}`; - const candidate = thinking ? `${base}-thinking` : base; - return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; - } - - if (family === "claude-4.6-sonnet") { - const thinking = - input.thinking === false - ? false - : input.thinking === true - ? true - : capability.defaultThinking; - const candidate = thinking ? "claude-4.6-sonnet-medium-thinking" : "claude-4.6-sonnet-medium"; - return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; - } - - return resolveModelSlugForProvider("cursor", family) as CursorModelSlug; -} - -const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; -const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6"; -const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5"; - export interface SelectableModelOption { slug: string; name: string; } -// ── Effort helpers ──────────────────────────────────────────────────── - /** Check whether a capabilities object includes a given effort value. */ export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean { return caps.reasoningEffortLevels.some((l) => l.value === value); @@ -509,12 +24,9 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null { /** * Resolve a raw effort option against capabilities. * - * Returns the effective effort value — the explicit value if supported and not - * prompt-injected, otherwise the model's default. Returns `undefined` only - * when the model has no effort levels at all. - * - * Prompt-injected efforts (e.g. "ultrathink") are excluded because they are - * applied via prompt text, not the effort API parameter. + * Returns the explicit supported value when present and not prompt-injected, + * otherwise the model default. Returns `undefined` when the model exposes no + * effort levels. */ export function resolveEffort( caps: ModelCapabilities, @@ -532,8 +44,6 @@ export function resolveEffort( return defaultValue ?? undefined; } -// ── Context window helpers ─────────────────────────────────────────── - /** Check whether a capabilities object includes a given context window value. */ export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean { return caps.contextWindowOptions.some((o) => o.value === value); @@ -547,14 +57,8 @@ export function getDefaultContextWindow(caps: ModelCapabilities): string | null /** * Resolve a raw `contextWindow` option against capabilities. * - * Returns the effective context window value — the explicit value if supported, - * otherwise the model's default. Returns `undefined` only when the model has - * no context window options at all. - * - * Unlike effort levels (where the API has matching defaults), the context - * window requires an explicit API suffix (e.g. `[1m]`), so we always preserve - * the resolved value to avoid ambiguity between "user chose the default" and - * "not specified". + * Returns the explicit supported value when present, otherwise the model + * default. Returns `undefined` when the model exposes no context window options. */ export function resolveContextWindow( caps: ModelCapabilities, @@ -619,23 +123,10 @@ export function resolveSelectableModel( } const resolved = options.find((option) => option.slug === normalized); - if (resolved) { - return resolved.slug; - } - - const familyMatch = options - .toSorted((left, right) => right.slug.length - left.slug.length) - .find((option) => { - if (!normalized.startsWith(option.slug)) { - return false; - } - const nextChar = normalized.charAt(option.slug.length); - return nextChar === "-" || nextChar === "[" || nextChar === ""; - }); - return familyMatch?.slug ?? null; + return resolved ? resolved.slug : null; } -export function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string { +function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string { const normalized = normalizeModelSlug(model, provider); if (!normalized) { return DEFAULT_MODEL_BY_PROVIDER[provider]; @@ -643,10 +134,6 @@ export function resolveModelSlug(model: string | null | undefined, provider: Pro return normalized; } -export function getDefaultModel(provider: ProviderKind = "codex"): string { - return DEFAULT_MODEL_BY_PROVIDER[provider]; -} - export function resolveModelSlugForProvider( provider: ProviderKind, model: string | null | undefined, @@ -661,205 +148,6 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } -export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean { - const slug = normalizeModelSlug(model, "claudeAgent"); - return slug === CLAUDE_OPUS_4_6_MODEL || slug === CLAUDE_SONNET_4_6_MODEL; -} - -export function supportsClaudeMaxEffort(model: string | null | undefined): boolean { - const slug = normalizeModelSlug(model, "claudeAgent"); - return slug === CLAUDE_OPUS_4_6_MODEL; -} - -export function supportsClaudeFastMode(model: string | null | undefined): boolean { - const slug = normalizeModelSlug(model, "claudeAgent"); - return slug === CLAUDE_OPUS_4_6_MODEL; -} - -export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean { - const slug = normalizeModelSlug(model, "claudeAgent"); - return slug === CLAUDE_OPUS_4_6_MODEL || slug === CLAUDE_SONNET_4_6_MODEL; -} - -export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean { - const slug = normalizeModelSlug(model, "claudeAgent"); - return slug === CLAUDE_HAIKU_4_5_MODEL; -} - -export function inferProviderForModel( - model: string | null | undefined, - fallback: ProviderKind = "codex", -): ProviderKind { - const normalizedClaude = normalizeModelSlug(model, "claudeAgent"); - if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) { - return "claudeAgent"; - } - - const normalizedCodex = normalizeModelSlug(model, "codex"); - if (normalizedCodex && MODEL_SLUG_SET_BY_PROVIDER.codex.has(normalizedCodex)) { - return "codex"; - } - - const normalizedCursor = normalizeModelSlug(model, "cursor"); - if (normalizedCursor && MODEL_SLUG_SET_BY_PROVIDER.cursor.has(normalizedCursor)) { - return "cursor"; - } - - if (typeof model === "string" && CURSOR_MODEL_FAMILY_SET.has(model.trim() as CursorModelFamily)) { - return "cursor"; - } - - return typeof model === "string" && model.trim().startsWith("claude-") ? "claudeAgent" : fallback; -} - -export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray; -export function getReasoningEffortOptions( - provider: "claudeAgent", - model?: string | null | undefined, -): ReadonlyArray; -export function getReasoningEffortOptions( - provider?: ProviderKind, - model?: string | null | undefined, -): ReadonlyArray; -export function getReasoningEffortOptions( - provider: ProviderKind = "codex", - model?: string | null | undefined, -): ReadonlyArray { - if (provider === "claudeAgent") { - if (supportsClaudeMaxEffort(model)) { - return ["low", "medium", "high", "max", "ultrathink"]; - } - if (supportsClaudeAdaptiveReasoning(model)) { - return ["low", "medium", "high", "ultrathink"]; - } - return []; - } - if (provider === "cursor") { - return []; - } - return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; -} - -export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; -export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort; -export function getDefaultReasoningEffort(provider: "cursor"): CursorReasoningOption; -export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort; -export function getDefaultReasoningEffort( - provider: ProviderKind = "codex", -): ProviderReasoningEffort { - return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; -} - -export function resolveReasoningEffortForProvider( - provider: "codex", - effort: string | null | undefined, -): CodexReasoningEffort | null; -export function resolveReasoningEffortForProvider( - provider: "claudeAgent", - effort: string | null | undefined, -): ClaudeCodeEffort | null; -export function resolveReasoningEffortForProvider( - provider: ProviderKind, - effort: string | null | undefined, -): ProviderReasoningEffort | null; -export function resolveReasoningEffortForProvider( - provider: ProviderKind, - effort: string | null | undefined, -): ProviderReasoningEffort | null { - if (typeof effort !== "string") { - return null; - } - - const trimmed = effort.trim(); - if (!trimmed) { - return null; - } - - const options = REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider] as ReadonlyArray; - return options.includes(trimmed) ? (trimmed as ProviderReasoningEffort) : null; -} - -export function isCursorModelFamilySlug(slug: string): boolean { - return CURSOR_MODEL_FAMILY_SET.has(slug as CursorModelFamily); -} - -export function getEffectiveClaudeCodeEffort( - effort: ClaudeCodeEffort | null | undefined, -): Exclude | null { - if (!effort) { - return null; - } - return effort === "ultrathink" ? null : effort; -} - -export function normalizeCodexModelOptions( - modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningEffort = - resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ?? - defaultReasoningEffort; - const fastModeEnabled = modelOptions?.fastMode === true; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), - ...(fastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - -export function normalizeClaudeModelOptions( - model: string | null | undefined, - modelOptions: ClaudeModelOptions | null | undefined, -): ClaudeModelOptions | undefined { - const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); - const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent"); - const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort); - const effort = - resolvedEffort && - resolvedEffort !== "ultrathink" && - reasoningOptions.includes(resolvedEffort) && - resolvedEffort !== defaultReasoningEffort - ? resolvedEffort - : undefined; - const thinking = - supportsClaudeThinkingToggle(model) && modelOptions?.thinking === false ? false : undefined; - const fastMode = - supportsClaudeFastMode(model) && modelOptions?.fastMode === true ? true : undefined; - const nextOptions: ClaudeModelOptions = { - ...(thinking === false ? { thinking: false } : {}), - ...(effort ? { effort } : {}), - ...(fastMode ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - -/** - * Resolve the actual API model identifier from a model selection. - * - * Provider-aware: each provider can map `contextWindow` (or other options) - * to whatever the API requires — a model-id suffix, a separate parameter, etc. - * The canonical slug stored in the selection stays unchanged so the - * capabilities system keeps working. - * - * Expects `contextWindow` to already be resolved (via `resolveContextWindow`) - * to the effective value, not stripped to `undefined` for defaults. - */ -export function resolveApiModelId(modelSelection: ModelSelection): string { - switch (modelSelection.provider) { - case "claudeAgent": { - switch (modelSelection.options?.contextWindow) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - return modelSelection.model; - } - } - default: { - return modelSelection.model; - } - } -} - export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined, From f5e64d610d543569ba05cee12d56fe78aba4290a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 18:21:25 -0700 Subject: [PATCH 10/82] fix picker --- .../src/provider/Layers/CursorProvider.ts | 2 +- .../components/chat/TraitsPicker.browser.tsx | 210 ++++++++++++++++++ apps/web/src/components/chat/TraitsPicker.tsx | 134 ++++++++--- .../chat/composerProviderRegistry.test.tsx | 42 ++++ .../chat/composerProviderRegistry.tsx | 197 ++++++++-------- apps/web/src/composerDraftStore.ts | 5 + apps/web/src/providerModels.ts | 2 + 7 files changed, 478 insertions(+), 114 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 62a06c0c94e..176736c13b3 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -131,7 +131,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ { value: "medium", label: "Medium" }, { value: "high", label: "High", isDefault: true }, ], - supportsFastMode: false, + supportsFastMode: true, supportsThinkingToggle: true, contextWindowOptions: [ { value: "200k", label: "200k", isDefault: true }, diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 99d09fd634d..24be4d3e544 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -4,6 +4,7 @@ import { type ModelSelection, ClaudeModelOptions, CodexModelOptions, + CursorModelOptions, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, ProjectId, @@ -475,3 +476,212 @@ describe("TraitsPicker (Codex)", () => { }); }); }); + +// ── Cursor TraitsPicker tests ───────────────────────────────────────── + +async function mountCursorPicker(props?: { + model?: string; + options?: CursorModelOptions; + models?: ServerProvider["models"]; +}) { + const threadId = ThreadId.makeUnsafe("thread-cursor-traits"); + const model = props?.model ?? "gpt-5.4"; + const cursorProvider = { + provider: "cursor", + enabled: true, + installed: true, + version: "0.1.0", + status: "ready", + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + models: props?.models ?? [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "272k", label: "272k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + promptInjectedEffortLevels: [], + }, + }, + ], + } satisfies ServerProvider; + + const draftsByThreadId: Record = { + [threadId]: { + prompt: "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: { + cursor: { + provider: "cursor", + model, + ...(props?.options ? { options: props.options } : {}), + }, + }, + activeProvider: "cursor", + runtimeMode: null, + interactionMode: null, + }, + }; + + useComposerDraftStore.setState({ + draftsByThreadId, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: { + [ProjectId.makeUnsafe("project-cursor-traits")]: threadId, + }, + }); + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + {}} + />, + { container: host }, + ); + + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + + return { + [Symbol.asyncDispose]: cleanup, + cleanup, + }; +} + +describe("TraitsPicker (Cursor)", () => { + afterEach(() => { + document.body.innerHTML = ""; + localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: {}, + }); + }); + + it("shows the selected Cursor context window in the trigger label", async () => { + await using _ = await mountCursorPicker({ + options: { contextWindow: "1m" }, + }); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Medium · 1M"); + }); + }); + + it("persists Cursor context window changes", async () => { + await using _ = await mountCursorPicker(); + + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "1M" }).click(); + + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toMatchObject({ + provider: "cursor", + model: "gpt-5.4", + options: { + contextWindow: "1m", + }, + }); + }); + + it("does not render for models with no trait capabilities", async () => { + await using _ = await mountCursorPicker({ + model: "default", + models: [ + { + slug: "default", + name: "Auto", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }); + + expect(document.querySelector("button")).toBeNull(); + }); + + it("renders for fast-only Cursor models with a usable label", async () => { + await using _ = await mountCursorPicker({ + model: "composer-2", + models: [ + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Normal"); + }); + }); + + it("persists fast-mode changes for fast-only Cursor models", async () => { + await using _ = await mountCursorPicker({ + model: "composer-2", + models: [ + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }); + + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "on" }).click(); + + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toMatchObject({ + provider: "cursor", + model: "composer-2", + options: { + fastMode: true, + }, + }); + }); +}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index f8181255d76..6a02e5004d8 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -69,8 +69,8 @@ function getRawContextWindow( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, ): string | null { - if (provider === "claudeAgent") { - return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.contextWindow); + if (modelOptions && "contextWindow" in modelOptions) { + return trimOrNull(modelOptions.contextWindow); } return null; } @@ -140,6 +140,49 @@ function getSelectedTraits( }; } +function getTraitsSectionVisibility(input: { + provider: ProviderKind; + models: ReadonlyArray; + model: string | null | undefined; + prompt: string; + modelOptions: ProviderOptions | null | undefined; + allowPromptInjectedEffort?: boolean; +}) { + const selected = getSelectedTraits( + input.provider, + input.models, + input.model, + input.prompt, + input.modelOptions, + input.allowPromptInjectedEffort ?? true, + ); + + const showEffort = selected.effort !== null; + const showThinking = selected.thinkingEnabled !== null; + const showFastMode = selected.caps.supportsFastMode; + const showContextWindow = selected.contextWindowOptions.length > 1; + + return { + ...selected, + showEffort, + showThinking, + showFastMode, + showContextWindow, + hasAnyControls: showEffort || showThinking || showFastMode || showContextWindow, + }; +} + +export function shouldRenderTraitsControls(input: { + provider: ProviderKind; + models: ReadonlyArray; + model: string | null | undefined; + prompt: string; + modelOptions: ProviderOptions | null | undefined; + allowPromptInjectedEffort?: boolean; +}): boolean { + return getTraitsSectionVisibility(input).hasAnyControls; +} + export interface TraitsMenuContentProps { provider: ProviderKind; models: ReadonlyArray; @@ -183,7 +226,19 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ contextWindow, defaultContextWindow, ultrathinkPromptControlled, - } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); + showEffort, + showThinking, + showFastMode, + showContextWindow, + hasAnyControls, + } = getTraitsSectionVisibility({ + provider, + models, + model, + prompt, + modelOptions, + allowPromptInjectedEffort, + }); const defaultEffort = getDefaultEffort(caps); const handleEffortChange = useCallback( @@ -217,13 +272,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ], ); - if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) { + if (!hasAnyControls) { return null; } return ( <> - {effort ? ( + {showEffort ? ( <>
Effort
@@ -246,7 +301,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
- ) : thinkingEnabled !== null ? ( + ) : showThinking ? (
Thinking
) : null} - {caps.supportsFastMode ? ( + {showFastMode ? ( <> - + {showEffort || showThinking ? : null}
Fast Mode
) : null} - {contextWindowOptions.length > 1 ? ( + {showContextWindow ? ( <> - + {showEffort || showThinking || showFastMode ? : null}
Context Window @@ -335,28 +390,57 @@ export const TraitsPicker = memo(function TraitsPicker({ contextWindow, defaultContextWindow, ultrathinkPromptControlled, - } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); + showEffort, + showThinking, + showContextWindow, + } = getTraitsSectionVisibility({ + provider, + models, + model, + prompt, + modelOptions, + allowPromptInjectedEffort, + }); const effortLabel = effort ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) : null; const contextWindowLabel = - contextWindowOptions.length > 1 && contextWindow !== defaultContextWindow + showContextWindow && contextWindow !== defaultContextWindow ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null) : null; - const triggerLabel = [ - ultrathinkPromptControlled - ? "Ultrathink" - : effortLabel - ? effortLabel - : thinkingEnabled === null - ? null - : `Thinking ${thinkingEnabled ? "On" : "Off"}`, - ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), - ...(contextWindowLabel ? [contextWindowLabel] : []), - ] - .filter(Boolean) - .join(" · "); + const fastOnlyControl = + caps.supportsFastMode && !showEffort && !showThinking && !showContextWindow; + if ( + !shouldRenderTraitsControls({ + provider, + models, + model, + prompt, + modelOptions, + allowPromptInjectedEffort, + }) + ) { + return null; + } + + const triggerLabel = fastOnlyControl + ? fastModeEnabled + ? "Fast" + : "Normal" + : [ + ultrathinkPromptControlled + ? "Ultrathink" + : effortLabel + ? effortLabel + : thinkingEnabled === null + ? null + : `Thinking ${thinkingEnabled ? "On" : "Off"}`, + ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), + ...(contextWindowLabel ? [contextWindowLabel] : []), + ] + .filter(Boolean) + .join(" · "); const isCodexStyle = provider === "codex"; diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 9ee8714adc0..53c235f92a6 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -64,6 +64,26 @@ const CURSOR_MODELS: ReadonlyArray = [ promptInjectedEffortLevels: [], }, }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra high" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "272k", label: "272k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + promptInjectedEffortLevels: [], + }, + }, ]; const CLAUDE_MODELS: ReadonlyArray = [ @@ -363,6 +383,28 @@ describe("getComposerProviderState", () => { }); }); + it("preserves Cursor context window in dispatch options", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "gpt-5.4", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { + contextWindow: "1m", + }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: "medium", + modelOptionsForDispatch: { + contextWindow: "1m", + }, + }); + }); + it("preserves Claude default effort explicitly in dispatch options", () => { const state = getComposerProviderState({ provider: "claudeAgent", diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 26ae498bce6..b7de6e0dccb 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -12,7 +12,7 @@ import { normalizeCodexModelOptionsWithCapabilities, normalizeCursorModelOptionsWithCapabilities, } from "../../providerModels"; -import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; +import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; @@ -98,99 +98,120 @@ function getProviderStateFromCapabilities( const composerProviderRegistry: Record = { codex: { getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: ({ - threadId, - model, - models, - modelOptions, - prompt, - onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + renderTraitsMenuContent: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "codex", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "codex", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, }, claudeAgent: { getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: ({ - threadId, - model, - models, - modelOptions, - prompt, - onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + renderTraitsMenuContent: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "claudeAgent", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "claudeAgent", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, }, cursor: { getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: ({ - threadId, - model, - models, - modelOptions, - prompt, - onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + renderTraitsMenuContent: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "cursor", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "cursor", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, }, }; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index b8f28f2ceec..58f0e0de439 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -507,6 +507,10 @@ function normalizeProviderModelOptions( : undefined; const cursorFastMode = cursorCandidate?.fastMode === true; const cursorThinkingFalse = cursorCandidate?.thinking === false; + const cursorContextWindow = + typeof cursorCandidate?.contextWindow === "string" && cursorCandidate.contextWindow.length > 0 + ? cursorCandidate.contextWindow + : undefined; const cursor: CursorModelOptions | undefined = cursorCandidate !== null @@ -514,6 +518,7 @@ function normalizeProviderModelOptions( ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), ...(cursorFastMode ? { fastMode: true } : {}), ...(cursorThinkingFalse ? { thinking: false } : {}), + ...(cursorContextWindow !== undefined ? { contextWindow: cursorContextWindow } : {}), } : undefined; diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 5f56a8f737d..e4fec6f1223 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -106,10 +106,12 @@ export function normalizeCursorModelOptionsWithCapabilities( const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; const thinking = caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); const nextOptions: CursorModelOptions = { ...(reasoningValue ? { reasoning: reasoningValue } : {}), ...(fastMode ? { fastMode: true } : {}), ...(thinking === false ? { thinking: false } : {}), + ...(contextWindow ? { contextWindow } : {}), }; return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } From 4d4bee2f68cde017652acf96a8f340734ffbe738 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 18:33:58 -0700 Subject: [PATCH 11/82] fix spawning --- .../src/provider/Layers/CursorAdapter.ts | 41 +++++-------------- .../src/provider/Layers/CursorProvider.ts | 12 ++++++ 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 28c78e4b688..fb43ecd1765 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -71,12 +71,6 @@ export interface CursorAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; } -interface CursorSpawnOptions { - readonly binaryPath?: string | undefined; - readonly args?: ReadonlyArray | undefined; - readonly apiEndpoint?: string | undefined; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -100,17 +94,6 @@ function extractModelConfigId(sessionResponse: unknown): string | undefined { return undefined; } -function buildCursorSpawnInput(cwd: string, opts?: CursorSpawnOptions, model?: string | undefined) { - const command = opts?.binaryPath?.trim() || "agent"; - const hasCustomArgs = opts?.args && opts.args.length > 0; - const args = [ - ...(opts?.apiEndpoint ? (["-e", opts.apiEndpoint] as const) : []), - ...(model && !hasCustomArgs ? (["--model", model] as const) : []), - ...(hasCustomArgs ? opts.args : (["acp"] as const)), - ]; - return { command, args, cwd } as const; -} - function toMessage(cause: unknown, fallback: string): string { if (cause instanceof Error && cause.message.length > 0) { return cause.message; @@ -678,7 +661,6 @@ interface PendingUserInput { interface CursorSessionContext { readonly threadId: ThreadId; session: ProviderSession; - readonly spawnOptions?: CursorSpawnOptions | undefined; readonly child: ChildProcessWithoutNullStreams; readonly conn: AcpJsonRpcConnection; acpSessionId: string; @@ -896,8 +878,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); } const cwd = nodePath.resolve(input.cwd.trim()); - const cursorSettings = yield* serverSettingsService.getSettings.pipe( + const spawnOptions = yield* serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.providers.cursor), + Effect.map((cursorSettings) => ({ + command: cursorSettings.binaryPath, + args: [ + ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), + "acp", + ], + cwd, + })), Effect.mapError( (error) => new ProviderAdapterProcessError({ @@ -908,22 +898,14 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }), ), ); - const cursorOpts: CursorSpawnOptions = { - binaryPath: cursorSettings.binaryPath, - apiEndpoint: cursorSettings.apiEndpoint || undefined, - }; const cursorModelSelection = input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; - const initialModel = resolveCursorDispatchModel( - cursorModelSelection?.model, - cursorModelSelection?.options, - ); const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { yield* stopSessionInternal(existing); } - const spawnInput = buildCursorSpawnInput(cwd, cursorOpts, initialModel); - const child = yield* spawnAcpChildProcess(spawnInput).pipe( + + const child = yield* spawnAcpChildProcess(spawnOptions).pipe( Effect.mapError( (e) => new ProviderAdapterProcessError({ @@ -950,7 +932,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const ctx: CursorSessionContext = { threadId: input.threadId, session: {} as ProviderSession, - spawnOptions: cursorOpts, child, conn, acpSessionId: "", diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 176736c13b3..06c6404f125 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -168,6 +168,18 @@ const BUILT_IN_MODELS: ReadonlyArray = [ promptInjectedEffortLevels: [], }, }, + { + slug: "grok-4-20", + name: "Grok 4.20", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, ]; export function getCursorModelCapabilities(model: string | null | undefined): ModelCapabilities { From 3a21c194617cd69a1a0e1c90b3d1b038c3e0ba86 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:07:02 -0700 Subject: [PATCH 12/82] Add Cursor text generation and model mapping - route Cursor commit/PR/branch generation through the agent CLI - resolve separate ACP and agent model IDs for Cursor models - improve git action failure logging and surface command output --- apps/server/src/git/Errors.ts | 7 + .../git/Layers/CursorTextGeneration.test.ts | 225 +++++++++++ .../src/git/Layers/CursorTextGeneration.ts | 350 ++++++++++++++++++ apps/server/src/git/Layers/GitManager.ts | 10 +- .../src/git/Layers/RoutingTextGeneration.ts | 29 +- .../src/provider/Layers/CursorAdapter.test.ts | 24 +- .../src/provider/Layers/CursorAdapter.ts | 4 +- .../provider/Layers/CursorProvider.test.ts | 55 ++- .../src/provider/Layers/CursorProvider.ts | 51 ++- 9 files changed, 727 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/git/Layers/CursorTextGeneration.test.ts create mode 100644 apps/server/src/git/Layers/CursorTextGeneration.ts diff --git a/apps/server/src/git/Errors.ts b/apps/server/src/git/Errors.ts index 15bf482f7bf..c6f42c6b516 100644 --- a/apps/server/src/git/Errors.ts +++ b/apps/server/src/git/Errors.ts @@ -36,6 +36,13 @@ export class TextGenerationError extends Schema.TaggedErrorClass&2', + " exit 11", + "fi", + ] + : []), + ...(input.requireTrust + ? [ + 'if [ "$seen_trust" != "1" ]; then', + ' printf "%s\\n" "missing --trust" >&2', + " exit 12", + "fi", + ] + : []), + ...(input.requireMode !== undefined + ? [ + `if [ "$mode" != "${input.requireMode}" ]; then`, + ' printf "%s\\n" "unexpected mode: $mode" >&2', + " exit 13", + "fi", + ] + : []), + ...(input.stdinMustContain !== undefined + ? [ + `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, + ' printf "%s\\n" "stdin missing expected content" >&2', + " exit 14", + "fi", + ] + : []), + ...(input.stderr !== undefined + ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] + : []), + "cat <<'__T3CODE_FAKE_AGENT_OUTPUT__'", + JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: input.result, + }), + "__T3CODE_FAKE_AGENT_OUTPUT__", + `exit ${input.exitCode ?? 0}`, + "", + ].join("\n"), + ); + yield* fs.chmod(agentPath, 0o755); + return agentPath; + }); +} + +function withFakeAgentEnv( + input: { + result: string; + requireModel?: string; + requireTrust?: boolean; + requireMode?: string; + stdinMustContain?: string; + stderr?: string; + exitCode?: number; + }, + effect: Effect.Effect, +) { + return Effect.acquireUseRelease( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-cursor-text-" }); + const agentPath = yield* makeFakeAgentBinary(tempDir, input); + const serverSettings = yield* ServerSettingsService; + const previousSettings = yield* serverSettings.getSettings; + yield* serverSettings.updateSettings({ + providers: { + cursor: { + binaryPath: agentPath, + }, + }, + }); + return { serverSettings, previousBinaryPath: previousSettings.providers.cursor.binaryPath }; + }), + () => effect, + ({ serverSettings, previousBinaryPath }) => + serverSettings + .updateSettings({ + providers: { + cursor: { + binaryPath: previousBinaryPath, + }, + }, + }) + .pipe(Effect.asVoid), + ); +} + +it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { + it.effect("uses agent CLI model ids instead of ACP bracket notation for commit messages", () => + withFakeAgentEnv( + { + result: JSON.stringify({ + subject: "Add generated commit message", + body: "- verify agent model mapping", + }), + requireModel: "composer-2-fast", + requireTrust: true, + requireMode: "ask", + stdinMustContain: "Staged patch:", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-text-generation", + stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", + modelSelection: { + provider: "cursor", + model: "composer-2", + options: { fastMode: true }, + }, + }); + + expect(generated.subject).toBe("Add generated commit message"); + expect(generated.body).toBe("- verify agent model mapping"); + }), + ), + ); + + it.effect("accepts json objects with extra text around them from agent output", () => + withFakeAgentEnv( + { + result: + 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', + requireModel: "composer-2", + requireTrust: true, + requireMode: "ask", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-noisy-json", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + provider: "cursor", + model: "composer-2", + }, + }); + + expect(generated.subject).toBe("Update README dummy comment with attribution and date"); + expect(generated.body).toBe(""); + }), + ), + ); +}); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts new file mode 100644 index 00000000000..2793df6555f --- /dev/null +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -0,0 +1,350 @@ +import { Effect, Layer, Option, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { CursorModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { TextGenerationError } from "../Errors.ts"; +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, +} from "../Prompts.ts"; +import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle } from "../Utils.ts"; +import { resolveCursorAgentModel } from "../../provider/Layers/CursorProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; + +const CURSOR_TIMEOUT_MS = 180_000; + +const CursorOutputEnvelope = Schema.Struct({ + type: Schema.String, + subtype: Schema.optional(Schema.String), + is_error: Schema.optional(Schema.Boolean), + result: Schema.optional(Schema.String), +}); + +function extractJsonObject(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + const start = trimmed.indexOf("{"); + if (start < 0) { + return trimmed; + } + + let depth = 0; + let inString = false; + let escaping = false; + for (let index = start; index < trimmed.length; index += 1) { + const char = trimmed[index]; + if (inString) { + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "{") { + depth += 1; + continue; + } + + if (char === "}") { + depth -= 1; + if (depth === 0) { + return trimmed.slice(start, index + 1); + } + } + } + + return trimmed.slice(start); +} + +const makeCursorTextGeneration = Effect.gen(function* () { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettingsService = yield* Effect.service(ServerSettingsService); + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("agent", operation, cause, "Failed to collect process output"), + ), + ); + + const runCursorJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: CursorModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const cursorSettings = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => settings.providers.cursor, + ).pipe(Effect.catch(() => Effect.undefined)); + + const runCursorCommand = Effect.gen(function* () { + const command = ChildProcess.make( + cursorSettings?.binaryPath || "agent", + [ + "-p", + "--trust", + "--mode", + "ask", + "--output-format", + "json", + "--model", + resolveCursorAgentModel(modelSelection.model, modelSelection.options), + ], + { + cwd, + shell: process.platform === "win32", + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }, + ); + + const child = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError("agent", operation, cause, "Failed to spawn Cursor Agent process"), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, child.stdout), + readStreamAsString(operation, child.stderr), + child.exitCode.pipe( + Effect.mapError((cause) => + normalizeCliError( + "agent", + operation, + cause, + "Failed to read Cursor Agent exit code", + ), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + const commandOutput = { stdout, stderr, exitCode }; + + if (exitCode !== 0) { + const stderrDetail = stderr.trim(); + const stdoutDetail = stdout.trim(); + const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + return yield* new TextGenerationError({ + operation, + commandOutput, + detail: + detail.length > 0 + ? `Cursor Agent command failed: ${detail}` + : `Cursor Agent command failed with code ${exitCode}.`, + }); + } + + return commandOutput; + }); + + const commandOutput = yield* runCursorCommand.pipe( + Effect.scoped, + Effect.timeoutOption(CURSOR_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + ); + + const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(CursorOutputEnvelope))( + commandOutput.stdout, + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent returned unexpected output format.", + commandOutput, + cause, + }), + ), + ), + ); + + if ( + envelope.type !== "result" || + envelope.subtype !== "success" || + envelope.is_error === true + ) { + return yield* new TextGenerationError({ + operation, + detail: "Cursor Agent returned an unsuccessful result.", + commandOutput, + }); + } + + const rawResult = envelope.result?.trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: "Cursor Agent returned empty output.", + commandOutput, + }); + } + + return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))( + extractJsonObject(rawResult), + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent returned invalid structured output.", + cause, + commandOutput, + }), + ), + ), + ); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "CursorTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + if (input.modelSelection.provider !== "cursor") { + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCursorJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "CursorTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + if (input.modelSelection.provider !== "cursor") { + return yield* new TextGenerationError({ + operation: "generatePrContent", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCursorJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "CursorTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + if (input.modelSelection.provider !== "cursor") { + return yield* new TextGenerationError({ + operation: "generateBranchName", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCursorJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + } satisfies TextGenerationShape; +}); + +export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 6fd86e1d58a..897b0f06e6a 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1268,13 +1268,15 @@ export const makeGitManager = Effect.gen(function* () { return yield* runAction.pipe( Effect.catch((error) => - progress - .emit({ + Effect.gen(function* () { + yield* Effect.logError("Failed to run action:", error); + yield* progress.emit({ kind: "action_failed", phase: currentPhase, message: error.message, - }) - .pipe(Effect.flatMap(() => Effect.fail(error))), + }); + return yield* Effect.fail(error); + }), ), ); }, diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 7915131385f..c61b1b6d115 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -18,6 +18,7 @@ import { } from "../Services/TextGeneration.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; +import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; // --------------------------------------------------------------------------- // Internal service tags so both concrete layers can coexist. @@ -31,6 +32,10 @@ class ClaudeTextGen extends ServiceMap.Service()( + "t3/git/Layers/RoutingTextGeneration/CursorTextGen", +) {} + // --------------------------------------------------------------------------- // Routing implementation // --------------------------------------------------------------------------- @@ -38,9 +43,15 @@ class ClaudeTextGen extends ServiceMap.Service - provider === "claudeAgent" ? claude : codex; + const providerToService = { + codex, + claudeAgent: claude, + cursor, + }; + + const route = (provider: TextGenerationProvider) => providerToService[provider]; return { generateCommitMessage: (input) => @@ -66,7 +77,19 @@ const InternalClaudeLayer = Layer.effect( }), ).pipe(Layer.provide(ClaudeTextGenerationLive)); +const InternalCursorLayer = Layer.effect( + CursorTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(CursorTextGenerationLive)); + export const RoutingTextGenerationLive = Layer.effect( TextGeneration, makeRoutingTextGeneration, -).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); +).pipe( + Layer.provide(InternalCodexLayer), + Layer.provide(InternalClaudeLayer), + Layer.provide(InternalCursorLayer), +); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index b97872b0336..a0af6596ec4 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -13,7 +13,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { makeCursorAdapterLive } from "./CursorAdapter.ts"; -import { resolveCursorDispatchModel } from "./CursorProvider.ts"; +import { resolveCursorAcpModelId } from "./CursorProvider.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); @@ -169,7 +169,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); - it.effect("selects the Cursor model via CLI argv instead of ACP request payloads", () => + it.effect("selects the Cursor model via ACP config updates instead of CLI argv", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; @@ -183,7 +183,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ); yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - const dispatchedModel = resolveCursorDispatchModel("composer-2", { fastMode: true }); + const dispatchedModel = resolveCursorAcpModelId("composer-2", { fastMode: true }); const session = yield* adapter.startSession({ threadId, provider: "cursor", @@ -198,11 +198,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, input: "probe model selection", attachments: [], + modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, }); yield* adapter.stopSession(threadId); const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); - assert.deepStrictEqual(argvRuns, [["--model", dispatchedModel, "acp"]]); + assert.deepStrictEqual(argvRuns, [["acp"]]); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const methods = requests @@ -223,6 +224,15 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { } } + const setConfigRequests = requests.filter( + (entry) => entry.method === "session/set_config_option", + ); + assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); + assert.equal( + (setConfigRequests[setConfigRequests.length - 1]?.params as Record)?.value, + dispatchedModel, + ); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); assert.isDefined(promptRequest); assert.deepStrictEqual( @@ -465,13 +475,17 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); - assert.deepStrictEqual(argvRuns[0], ["--model", "composer-2[fast=false]", "acp"]); + assert.deepStrictEqual(argvRuns[0], ["acp"]); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const setConfigRequests = requests.filter( (entry) => entry.method === "session/set_config_option", ); assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); + assert.equal( + (setConfigRequests[0]?.params as Record)?.value, + "composer-2[fast=false]", + ); const lastSetConfig = setConfigRequests[setConfigRequests.length - 1]; assert.equal( (lastSetConfig?.params as Record)?.value, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index fb43ecd1765..681f7746a5b 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -56,7 +56,7 @@ import { import type { AcpInboundMessage } from "../acp/AcpTypes.ts"; import { AcpProcessExitedError, AcpRpcError, type AcpError } from "../acp/AcpErrors.ts"; import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; -import { resolveCursorDispatchModel } from "./CursorProvider.ts"; +import { resolveCursorAcpModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "cursor" as const; @@ -1285,7 +1285,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const turnId = TurnId.makeUnsafe(crypto.randomUUID()); const turnModelSelection = input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; - const model = resolveCursorDispatchModel( + const model = resolveCursorAcpModelId( turnModelSelection?.model ?? ctx.session.model, turnModelSelection?.options, ); diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 3f5094b82ed..331a9090fed 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1,36 +1,38 @@ import { describe, expect, it } from "vitest"; -import { getCursorModelCapabilities, resolveCursorDispatchModel } from "./CursorProvider.ts"; +import { + getCursorModelCapabilities, + resolveCursorAgentModel, + resolveCursorAcpModelId, +} from "./CursorProvider.ts"; -describe("resolveCursorDispatchModel", () => { +describe("resolveCursorAcpModelId", () => { it("builds bracket notation from canonical base slugs and capabilities", () => { - expect(resolveCursorDispatchModel("composer-2", { fastMode: true })).toBe( - "composer-2[fast=true]", - ); - expect(resolveCursorDispatchModel("gpt-5.4", undefined)).toBe( + expect(resolveCursorAcpModelId("composer-2", { fastMode: true })).toBe("composer-2[fast=true]"); + expect(resolveCursorAcpModelId("gpt-5.4", undefined)).toBe( "gpt-5.4[reasoning=medium,context=272k,fast=false]", ); expect( - resolveCursorDispatchModel("claude-opus-4-6", { + resolveCursorAcpModelId("claude-opus-4-6", { reasoning: "high", thinking: true, contextWindow: "1m", }), - ).toBe("claude-opus-4-6[effort=high,thinking=true,context=1m]"); + ).toBe("claude-opus-4-6[effort=high,thinking=true,context=1m,fast=false]"); }); it("maps legacy cursor aliases onto the canonical base slug", () => { - expect(resolveCursorDispatchModel("gpt-5.4-1m", undefined)).toBe( + expect(resolveCursorAcpModelId("gpt-5.4-1m", undefined)).toBe( "gpt-5.4[reasoning=medium,context=272k,fast=false]", ); - expect(resolveCursorDispatchModel("auto", undefined)).toBe("default[]"); - expect(resolveCursorDispatchModel("claude-4.6-opus", undefined)).toBe( - "claude-opus-4-6[effort=high,thinking=true,context=200k]", + expect(resolveCursorAcpModelId("auto", undefined)).toBe("default[]"); + expect(resolveCursorAcpModelId("claude-4.6-opus", undefined)).toBe( + "claude-opus-4-6[effort=high,thinking=true,context=200k,fast=false]", ); }); it("passes custom models through unchanged", () => { - expect(resolveCursorDispatchModel("custom/internal-model", undefined)).toBe( + expect(resolveCursorAcpModelId("custom/internal-model", undefined)).toBe( "custom/internal-model[]", ); }); @@ -45,3 +47,30 @@ describe("getCursorModelCapabilities", () => { expect(getCursorModelCapabilities("claude-opus-4-6").supportsThinkingToggle).toBe(true); }); }); + +describe("resolveCursorAgentModel", () => { + it("maps canonical base slugs onto agent CLI model ids", () => { + expect(resolveCursorAgentModel("composer-2", { fastMode: true })).toBe("composer-2-fast"); + expect(resolveCursorAgentModel("gpt-5.3-codex", { reasoning: "xhigh" })).toBe( + "gpt-5.3-codex-xhigh", + ); + expect( + resolveCursorAgentModel("gpt-5.4", { + reasoning: "medium", + fastMode: true, + contextWindow: "272k", + }), + ).toBe("gpt-5.4-medium-fast"); + expect(resolveCursorAgentModel("claude-opus-4-6", { thinking: true })).toBe( + "claude-4.6-opus-high-thinking", + ); + expect(resolveCursorAgentModel("auto", undefined)).toBe("auto"); + }); + + it("passes custom agent model ids through unchanged", () => { + expect(resolveCursorAgentModel("gpt-5.4-mini-medium", undefined)).toBe("gpt-5.4-mini-medium"); + expect(resolveCursorAgentModel("custom/internal-model", undefined)).toBe( + "custom/internal-model", + ); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 06c6404f125..76ef30d6b45 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -189,7 +189,10 @@ export function getCursorModelCapabilities(model: string | null | undefined): Mo ); } -export function resolveCursorDispatchModel( +/** + * Resolve the ACP model ID for a Cursor model to be sent to session/set_config_option + */ +export function resolveCursorAcpModelId( model: string | null | undefined, modelOptions: CursorModelOptions | null | undefined, ): string { @@ -226,6 +229,52 @@ export function resolveCursorDispatchModel( return `${slug}[${traits.join(",")}]`; } +/** + * Resolve the Agent CLI model ID for a Cursor model to be set as `--model` arg for the `agent` command. + * + * Yes... Cursor uses different IDs. No... I don't know why. + */ +export function resolveCursorAgentModel( + model: string | null | undefined, + modelOptions: CursorModelOptions | null | undefined, +): string { + const normalized = normalizeModelSlug(model, "cursor") ?? "default"; + const slug = normalized.includes("[") ? normalized.slice(0, normalized.indexOf("[")) : normalized; + const caps = getCursorModelCapabilities(slug); + const reasoning = resolveEffort(caps, modelOptions?.reasoning); + const thinking = caps.supportsThinkingToggle ? (modelOptions?.thinking ?? true) : undefined; + const fastMode = modelOptions?.fastMode === true; + + switch (slug) { + case "default": + return "auto"; + case "composer-2": + return fastMode ? "composer-2-fast" : "composer-2"; + case "composer-1.5": + return "composer-1.5"; + case "gpt-5.3-codex": { + const suffix = reasoning && reasoning !== "medium" ? `-${reasoning}` : ""; + return `gpt-5.3-codex${suffix}${fastMode ? "-fast" : ""}`; + } + case "gpt-5.3-codex-spark": { + const suffix = reasoning && reasoning !== "medium" ? `-${reasoning}` : ""; + return `gpt-5.3-codex-spark-preview${suffix}`; + } + case "gpt-5.4": + return `gpt-5.4-${reasoning ?? "medium"}${fastMode ? "-fast" : ""}`; + case "claude-opus-4-6": + return thinking ? "claude-4.6-opus-high-thinking" : "claude-4.6-opus-high"; + case "claude-sonnet-4-6": + return thinking ? "claude-4.6-sonnet-medium-thinking" : "claude-4.6-sonnet-medium"; + case "gemini-3.1-pro": + return "gemini-3.1-pro"; + case "grok-4-20": + return thinking ? "grok-4-20-thinking" : "grok-4-20"; + default: + return slug === "default" ? "auto" : slug; + } +} + /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ const ABOUT_TIMEOUT_MS = 8_000; From 88696b4bb4a768e89e520963f4bc42b42a219f4d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:09:12 -0700 Subject: [PATCH 13/82] fix effect lsp issues --- apps/server/src/git/Layers/GitManager.ts | 2 +- apps/server/src/provider/acp/AcpTypes.ts | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 897b0f06e6a..3b762e11168 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1275,7 +1275,7 @@ export const makeGitManager = Effect.gen(function* () { phase: currentPhase, message: error.message, }); - return yield* Effect.fail(error); + return yield* error; }), ), ); diff --git a/apps/server/src/provider/acp/AcpTypes.ts b/apps/server/src/provider/acp/AcpTypes.ts index cc593a4f796..2a7ff9138fd 100644 --- a/apps/server/src/provider/acp/AcpTypes.ts +++ b/apps/server/src/provider/acp/AcpTypes.ts @@ -19,22 +19,19 @@ export const JsonRpcInboundWire = Schema.Struct({ error: Schema.optional(JsonRpcErrorPayload), }); -export const AcpInboundResponse = Schema.Struct({ - _tag: Schema.Literal("response"), +export const AcpInboundResponse = Schema.TaggedStruct("response", { id: Schema.Union([Schema.String, Schema.Number]), result: Schema.optional(Schema.Unknown), error: Schema.optional(JsonRpcErrorPayload), }); -export const AcpInboundRequest = Schema.Struct({ - _tag: Schema.Literal("request"), +export const AcpInboundRequest = Schema.TaggedStruct("request", { id: Schema.Union([Schema.String, Schema.Number]), method: Schema.String, params: Schema.optional(Schema.Unknown), }); -export const AcpInboundNotification = Schema.Struct({ - _tag: Schema.Literal("notification"), +export const AcpInboundNotification = Schema.TaggedStruct("notification", { method: Schema.String, params: Schema.optional(Schema.Unknown), }); From ea4780502bfec8580b8e9a06f57d55c95d37c406 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:11:27 -0700 Subject: [PATCH 14/82] use constructors --- apps/server/src/provider/acp/AcpTypes.ts | 37 +++++++++++++----------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/server/src/provider/acp/AcpTypes.ts b/apps/server/src/provider/acp/AcpTypes.ts index 2a7ff9138fd..59dc7788c63 100644 --- a/apps/server/src/provider/acp/AcpTypes.ts +++ b/apps/server/src/provider/acp/AcpTypes.ts @@ -64,29 +64,32 @@ const jsonRpcWireToInbound = SchemaTransformation.transformOrFail({ ...(err.data !== undefined ? { data: err.data } : {}), } : undefined; - return Effect.succeed({ - _tag: "response" as const, - id, - ...(parsed.result !== undefined ? { result: parsed.result } : {}), - ...(rpcError ? { error: rpcError } : {}), - }); + return Effect.succeed( + AcpInboundResponse.makeUnsafe({ + id, + ...(parsed.result !== undefined ? { result: parsed.result } : {}), + ...(rpcError ? { error: rpcError } : {}), + }), + ); } if (hasMethod && hasId) { - return Effect.succeed({ - _tag: "request" as const, - id, - method, - ...(parsed.params !== undefined ? { params: parsed.params } : {}), - }); + return Effect.succeed( + AcpInboundRequest.makeUnsafe({ + id, + method, + ...(parsed.params !== undefined ? { params: parsed.params } : {}), + }), + ); } if (hasMethod && !hasId) { - return Effect.succeed({ - _tag: "notification" as const, - method, - ...(parsed.params !== undefined ? { params: parsed.params } : {}), - }); + return Effect.succeed( + AcpInboundNotification.makeUnsafe({ + method, + ...(parsed.params !== undefined ? { params: parsed.params } : {}), + }), + ); } return Effect.fail( From 97ed04de25a7ee74f1568d0fc8eef369a5e87099 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:13:59 -0700 Subject: [PATCH 15/82] kewl --- apps/web/src/store.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 9460181a998..b53b48a770e 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,6 +1,6 @@ import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { - type ProviderKind, + ProviderKind, ThreadId, type OrchestrationReadModel, type OrchestrationSessionStatus, @@ -9,6 +9,7 @@ import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; import { type ChatMessage, type Project, type Thread } from "./types"; import { Debouncer } from "@tanstack/react-pacer"; +import { Schema } from "effect"; // ── State ──────────────────────────────────────────────────────────── @@ -193,7 +194,7 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex" || providerName === "claudeAgent" || providerName === "cursor") { + if (Schema.is(ProviderKind)(providerName)) { return providerName; } return "codex"; From 0575c118e7afddf89c1bd1015d6ed1eaab35e6a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:17:59 -0700 Subject: [PATCH 16/82] move claude model id lookup --- .../src/git/Layers/ClaudeTextGeneration.ts | 6 ++-- .../src/provider/Layers/ClaudeAdapter.ts | 3 +- .../src/provider/Layers/ClaudeModelId.test.ts | 28 ------------------- .../src/provider/Layers/ClaudeModelId.ts | 10 ------- .../src/provider/Layers/ClaudeProvider.ts | 10 +++++++ 5 files changed, 15 insertions(+), 42 deletions(-) delete mode 100644 apps/server/src/provider/Layers/ClaudeModelId.test.ts delete mode 100644 apps/server/src/provider/Layers/ClaudeModelId.ts diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 2b9fc26e51e..46ec2559842 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -26,8 +26,10 @@ import { sanitizePrTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeModelId.ts"; -import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; +import { + normalizeClaudeModelOptions, + resolveClaudeApiModelId, +} from "../../provider/Layers/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; const CLAUDE_TIMEOUT_MS = 180_000; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3fda9626877..0200c61546d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -59,8 +59,7 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; -import { resolveClaudeApiModelId } from "./ClaudeModelId.ts"; +import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, diff --git a/apps/server/src/provider/Layers/ClaudeModelId.test.ts b/apps/server/src/provider/Layers/ClaudeModelId.test.ts deleted file mode 100644 index 5102047fc7d..00000000000 --- a/apps/server/src/provider/Layers/ClaudeModelId.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveClaudeApiModelId } from "./ClaudeModelId.ts"; - -describe("resolveClaudeApiModelId", () => { - it("appends [1m] for 1m context window", () => { - expect( - resolveClaudeApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "1m" }, - }), - ).toBe("claude-opus-4-6[1m]"); - }); - - it("returns the canonical slug for default context windows", () => { - expect( - resolveClaudeApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "200k" }, - }), - ).toBe("claude-opus-4-6"); - expect(resolveClaudeApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( - "claude-opus-4-6", - ); - }); -}); diff --git a/apps/server/src/provider/Layers/ClaudeModelId.ts b/apps/server/src/provider/Layers/ClaudeModelId.ts deleted file mode 100644 index a4371c80a51..00000000000 --- a/apps/server/src/provider/Layers/ClaudeModelId.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ClaudeModelSelection } from "@t3tools/contracts"; - -export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { - switch (modelSelection.options?.contextWindow) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - return modelSelection.model; - } -} diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b67b90e8791..4f7d8110ff1 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,6 +1,7 @@ import type { ClaudeSettings, ClaudeModelOptions, + ClaudeModelSelection, ModelCapabilities, ServerProvider, ServerProviderModel, @@ -96,6 +97,15 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo ); } +export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { + switch (modelSelection.options?.contextWindow) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; + } +} + export function normalizeClaudeModelOptions( model: string | null | undefined, modelOptions: ClaudeModelOptions | null | undefined, From aa5b8d23450d4be47d3c38817de8ca28f579eba5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:19:51 -0700 Subject: [PATCH 17/82] rm unused test --- .../Layers/ProviderCommandReactor.test.ts | 45 ------------------- 1 file changed, 45 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b85899c4261..f74ce3a5a8d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -611,51 +611,6 @@ describe("ProviderCommandReactor", () => { }); }); - it("routes turns by explicit provider even when the model slug is shared", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create-cursor-shared-slug"), - threadId: ThreadId.makeUnsafe("thread-shared-slug"), - projectId: asProjectId("project-1"), - title: "Shared slug thread", - modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - branch: null, - worktreePath: null, - createdAt: now, - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-shared-slug"), - threadId: ThreadId.makeUnsafe("thread-shared-slug"), - message: { - messageId: asMessageId("user-message-shared-slug"), - role: "user", - text: "first", - attachments: [], - }, - modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.startSession.mock.calls.length === 1); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, - }); - }); - it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); From 26ef535a254f7280407cc0724be0c576b900b0d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:37:51 -0700 Subject: [PATCH 18/82] kewl --- .../src/provider/Layers/CursorAdapter.ts | 7 +- apps/server/src/provider/acp/AcpErrors.ts | 65 +++++++++++++------ .../src/provider/acp/AcpJsonRpcConnection.ts | 28 ++++---- 3 files changed, 67 insertions(+), 33 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 681f7746a5b..804a806a2e9 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -33,6 +33,7 @@ import { Layer, Queue, Random, + Schema, Stream, } from "effect"; @@ -106,14 +107,14 @@ function mapAcpToAdapterError( method: string, error: AcpError, ): ProviderAdapterError { - if (error instanceof AcpProcessExitedError) { + if (Schema.is(AcpProcessExitedError)(error)) { return new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId, cause: error, }); } - if (error instanceof AcpRpcError) { + if (Schema.is(AcpRpcError)(error)) { return new ProviderAdapterRequestError({ provider: PROVIDER, method, @@ -535,7 +536,7 @@ function updateSessionModeState( function isMethodNotFoundRpcError(error: AcpError): boolean { return ( - error instanceof AcpRpcError && + Schema.is(AcpRpcError)(error) && (error.code === -32601 || error.message.toLowerCase().includes("method not found")) ); } diff --git a/apps/server/src/provider/acp/AcpErrors.ts b/apps/server/src/provider/acp/AcpErrors.ts index 40b35ca3167..edbc10677d7 100644 --- a/apps/server/src/provider/acp/AcpErrors.ts +++ b/apps/server/src/provider/acp/AcpErrors.ts @@ -1,24 +1,51 @@ -import { Data } from "effect"; +import { Schema } from "effect"; -export class AcpSpawnError extends Data.TaggedError("AcpSpawnError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class AcpSpawnError extends Schema.TaggedErrorClass()("AcpSpawnError", { + command: Schema.optional(Schema.String), + args: Schema.optional(Schema.Array(Schema.String)), + shell: Schema.optional(Schema.Boolean), + cause: Schema.optional(Schema.Defect), +}) { + override get message() { + return `Failed to spawn ACP process: ${this.cause instanceof Error ? this.cause.message : String(this.cause)}`; + } +} -export class AcpParseError extends Data.TaggedError("AcpParseError")<{ - readonly line: string; - readonly cause?: unknown; -}> {} +export class AcpParseError extends Schema.TaggedErrorClass()("AcpParseError", { + line: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message() { + return `Failed to parse ACP message: ${this.line}`; + } +} +export class AcpRpcError extends Schema.TaggedErrorClass()("AcpRpcError", { + code: Schema.Number, + errorMessage: Schema.optional(Schema.String), + data: Schema.optional(Schema.Unknown), +}) { + override get message() { + return `Failed to send ACP RPC message (code: ${this.code}, message: ${this.errorMessage}, data: ${JSON.stringify(this.data)})`; + } +} -export class AcpRpcError extends Data.TaggedError("AcpRpcError")<{ - readonly code: number; - readonly message: string; - readonly data?: unknown; -}> {} +export class AcpProcessExitedError extends Schema.TaggedErrorClass()( + "AcpProcessExitedError", + { + code: Schema.NullOr(Schema.Number), + signal: Schema.NullOr(Schema.String), + }, +) { + override get message() { + return `ACP process exited with code ${this.code} and signal ${this.signal}`; + } +} -export class AcpProcessExitedError extends Data.TaggedError("AcpProcessExitedError")<{ - readonly code: number | null; - readonly signal: NodeJS.Signals | null; -}> {} +export const AcpError = Schema.Union([ + AcpSpawnError, + AcpParseError, + AcpRpcError, + AcpProcessExitedError, +]); -export type AcpError = AcpSpawnError | AcpParseError | AcpRpcError | AcpProcessExitedError; +export type AcpError = typeof AcpError.Type; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.ts index cc44e46af96..bb73a26f49e 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.ts @@ -1,14 +1,25 @@ import { createInterface } from "node:readline"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { Cause, Deferred, Effect, Exit, Queue, Ref, Scope, Semaphore, Stream } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Queue, + Ref, + Schema, + Scope, + Semaphore, + Stream, +} from "effect"; import { + AcpError, AcpParseError, AcpProcessExitedError, AcpRpcError, AcpSpawnError, - type AcpError, } from "./AcpErrors.ts"; import { decodeAcpInboundFromJsonLine, @@ -43,24 +54,21 @@ export interface AcpJsonRpcConnection { export function spawnAcpChildProcess( input: AcpSpawnInput, ): Effect.Effect { + const shell = process.platform === "win32"; return Effect.try({ try: () => { const c = spawn(input.command, [...input.args], { cwd: input.cwd, env: { ...process.env, ...input.env }, stdio: ["pipe", "pipe", "inherit"], - shell: process.platform === "win32", + shell, }); if (!c.stdin || !c.stdout) { throw new Error("Child process missing stdio pipes."); } return c as unknown as ChildProcessWithoutNullStreams; }, - catch: (cause) => - new AcpSpawnError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), + catch: (cause) => new AcpSpawnError({ command: input.command, args: input.args, shell, cause }), }); } @@ -109,7 +117,6 @@ export const attachAcpJsonRpcConnection = ( }, catch: (cause) => new AcpSpawnError({ - message: cause instanceof Error ? cause.message : String(cause), cause, }), }); @@ -176,7 +183,6 @@ export const attachAcpJsonRpcConnection = ( def, new AcpRpcError({ code: msg.error.code, - message: msg.error.message, ...(msg.error.data !== undefined ? { data: msg.error.data } : {}), }), ); @@ -203,7 +209,7 @@ export const attachAcpJsonRpcConnection = ( yield* respondResult(msg.id, exit.value); } else { const left = Cause.squash(exit.cause); - yield* respondError(msg.id, left instanceof AcpRpcError ? left.message : String(left)); + yield* respondError(msg.id, Schema.is(AcpError)(left) ? left.message : String(left)); } }); From c029bac8e1b7a1bae46f0fdd8478dc7312d0cff9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:51:36 -0700 Subject: [PATCH 19/82] nit --- .../Layers/ProviderSessionDirectory.ts | 21 +- apps/server/src/provider/acp/AcpTypes.ts | 2 +- apps/server/src/provider/acp/index.ts | 3 - .../CompactComposerControlsMenu.browser.tsx | 40 +--- .../chat/ProviderModelPicker.browser.tsx | 83 +------ .../components/chat/TraitsPicker.browser.tsx | 210 ------------------ .../chat/composerProviderRegistry.tsx | 11 +- 7 files changed, 24 insertions(+), 346 deletions(-) delete mode 100644 apps/server/src/provider/acp/index.ts diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index b6c51db8854..deafe2b1482 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,5 +1,5 @@ -import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; -import { Effect, Layer, Option } from "effect"; +import { ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { Effect, Layer, Option, Schema } from "effect"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; @@ -22,14 +22,15 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent" || providerName === "cursor") { - return Effect.succeed(providerName); - } - return Effect.fail( - new ProviderSessionDirectoryPersistenceError({ - operation, - detail: `Unknown persisted provider '${providerName}'.`, - }), + return Schema.decodeUnknownEffect(ProviderKind)(providerName).pipe( + Effect.mapError( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation, + detail: `Unknown persisted provider '${providerName}'.`, + cause, + }), + ), ); } diff --git a/apps/server/src/provider/acp/AcpTypes.ts b/apps/server/src/provider/acp/AcpTypes.ts index 59dc7788c63..6917c70bc43 100644 --- a/apps/server/src/provider/acp/AcpTypes.ts +++ b/apps/server/src/provider/acp/AcpTypes.ts @@ -94,7 +94,7 @@ const jsonRpcWireToInbound = SchemaTransformation.transformOrFail({ return Effect.fail( new SchemaIssue.InvalidValue(Option.some(parsed), { - title: "Unrecognized JSON-RPC inbound message shape", + message: "Unrecognized JSON-RPC inbound message shape", }), ); }, diff --git a/apps/server/src/provider/acp/index.ts b/apps/server/src/provider/acp/index.ts deleted file mode 100644 index ca1d664a702..00000000000 --- a/apps/server/src/provider/acp/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./AcpErrors.ts"; -export * from "./AcpTypes.ts"; -export * from "./AcpJsonRpcConnection.ts"; diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 8275f4c6f5b..658ee8ae7b7 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -112,27 +112,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str }, }, ] - : provider === "cursor" - ? [ - { - slug: "gpt-5.3-codex", - name: "Codex 5.3", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra high" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ] - : []; + : []; const screen = await render( { }); }); - it("shows Cursor reasoning controls for GPT-5.3 Codex family", async () => { - const mounted = await mountMenu({ - modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, - }); - - try { - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Reasoning"); - expect(text).toContain("Fast mode"); - }); - } finally { - await mounted.cleanup(); - } - }); - it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => { await using _ = await mountMenu({ modelSelection: { diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 9b4dd24e95e..679cbff3212 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -108,58 +108,6 @@ const TEST_PROVIDERS: ReadonlyArray = [ }, ], }, - { - provider: "cursor", - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - authStatus: "authenticated", - checkedAt: new Date().toISOString(), - models: [ - { - slug: "composer-2", - name: "Composer 2", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex", - name: "Codex 5.3", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - effort("low"), - effort("medium", true), - effort("high"), - effort("xhigh"), - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }, ]; async function mountPicker(props: { @@ -173,14 +121,12 @@ async function mountPicker(props: { document.body.append(host); const onProviderModelChange = vi.fn(); const providers = props.providers ?? TEST_PROVIDERS; - const modelOptionsByProvider = { - ...getCustomModelOptionsByProvider( - DEFAULT_UNIFIED_SETTINGS, - providers, - props.provider, - props.model, - ), - }; + const modelOptionsByProvider = getCustomModelOptionsByProvider( + DEFAULT_UNIFIED_SETTINGS, + providers, + props.provider, + props.model, + ); const screen = await render( { } }); - it("uses canonical Cursor slugs from the server-provided model options", async () => { - const mounted = await mountPicker({ - provider: "cursor", - model: "claude-opus-4-6", - lockedProvider: "cursor", - }); - - try { - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "Codex 5.3" }).click(); - - expect(mounted.onProviderModelChange).toHaveBeenCalledWith("cursor", "gpt-5.3-codex"); - } finally { - await mounted.cleanup(); - } - }); - it("dispatches the canonical slug when a model is selected", async () => { const mounted = await mountPicker({ provider: "claudeAgent", diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 24be4d3e544..99d09fd634d 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -4,7 +4,6 @@ import { type ModelSelection, ClaudeModelOptions, CodexModelOptions, - CursorModelOptions, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, ProjectId, @@ -476,212 +475,3 @@ describe("TraitsPicker (Codex)", () => { }); }); }); - -// ── Cursor TraitsPicker tests ───────────────────────────────────────── - -async function mountCursorPicker(props?: { - model?: string; - options?: CursorModelOptions; - models?: ServerProvider["models"]; -}) { - const threadId = ThreadId.makeUnsafe("thread-cursor-traits"); - const model = props?.model ?? "gpt-5.4"; - const cursorProvider = { - provider: "cursor", - enabled: true, - installed: true, - version: "0.1.0", - status: "ready", - authStatus: "authenticated", - checkedAt: "2026-01-01T00:00:00.000Z", - models: props?.models ?? [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "272k", label: "272k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: [], - }, - }, - ], - } satisfies ServerProvider; - - const draftsByThreadId: Record = { - [threadId]: { - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: { - cursor: { - provider: "cursor", - model, - ...(props?.options ? { options: props.options } : {}), - }, - }, - activeProvider: "cursor", - runtimeMode: null, - interactionMode: null, - }, - }; - - useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: { - [ProjectId.makeUnsafe("project-cursor-traits")]: threadId, - }, - }); - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - {}} - />, - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -describe("TraitsPicker (Cursor)", () => { - afterEach(() => { - document.body.innerHTML = ""; - localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows the selected Cursor context window in the trigger label", async () => { - await using _ = await mountCursorPicker({ - options: { contextWindow: "1m" }, - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Medium · 1M"); - }); - }); - - it("persists Cursor context window changes", async () => { - await using _ = await mountCursorPicker(); - - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "1M" }).click(); - - expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toMatchObject({ - provider: "cursor", - model: "gpt-5.4", - options: { - contextWindow: "1m", - }, - }); - }); - - it("does not render for models with no trait capabilities", async () => { - await using _ = await mountCursorPicker({ - model: "default", - models: [ - { - slug: "default", - name: "Auto", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }); - - expect(document.querySelector("button")).toBeNull(); - }); - - it("renders for fast-only Cursor models with a usable label", async () => { - await using _ = await mountCursorPicker({ - model: "composer-2", - models: [ - { - slug: "composer-2", - name: "Composer 2", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Normal"); - }); - }); - - it("persists fast-mode changes for fast-only Cursor models", async () => { - await using _ = await mountCursorPicker({ - model: "composer-2", - models: [ - { - slug: "composer-2", - name: "Composer 2", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }); - - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "on" }).click(); - - expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toMatchObject({ - provider: "cursor", - model: "composer-2", - options: { - fastMode: true, - }, - }); - }); -}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index b7de6e0dccb..360dafaa4ef 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -72,12 +72,11 @@ function getProviderStateFromCapabilities( const promptEffort = resolveEffort(caps, rawEffort) ?? null; // Normalize options for dispatch - const normalizedOptions = - provider === "codex" - ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) - : provider === "cursor" - ? normalizeCursorModelOptionsWithCapabilities(caps, providerOptions) - : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); + const normalizedOptions = { + codex: normalizeCodexModelOptionsWithCapabilities(caps, providerOptions), + cursor: normalizeCursorModelOptionsWithCapabilities(caps, providerOptions), + claudeAgent: normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions), + }[provider]; // Ultrathink styling (driven by capabilities data, not provider identity) const ultrathinkActive = From f7b2b07bcf7030ae8d0cec3d8620ca932b87821f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 20:42:50 -0700 Subject: [PATCH 20/82] kewl --- apps/server/src/provider/Layers/ProviderService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 0137152e83d..9f16d94bf40 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -151,7 +151,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Effect.succeed(event).pipe( Effect.tap((canonicalEvent) => - canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void, + canonicalEventLogger + ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) + : Effect.void, ), Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), Effect.asVoid, From accc67ddb73d0c5c811e0982c8c9748bff9e6f50 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 04:46:49 -0700 Subject: [PATCH 21/82] effect-acp --- bun.lock | 26 + package.json | 1 + packages/effect-acp/package.json | 97 + packages/effect-acp/scripts/generate.ts | 269 + .../effect-acp/src/_generated/meta.gen.ts | 32 + .../effect-acp/src/_generated/schema.gen.ts | 10156 ++++++++++++++++ packages/effect-acp/src/child-process.ts | 21 + packages/effect-acp/src/client.test.ts | 100 + packages/effect-acp/src/client.ts | 356 + packages/effect-acp/src/errors.ts | 182 + packages/effect-acp/src/protocol.test.ts | 147 + packages/effect-acp/src/protocol.ts | 485 + packages/effect-acp/src/rpc.ts | 159 + packages/effect-acp/src/server.ts | 135 + packages/effect-acp/src/terminal.ts | 49 + .../examples/cursor-acp-client.example.ts | 55 + .../effect-acp/test/fixtures/acp-mock-peer.ts | 222 + packages/effect-acp/tsconfig.json | 18 + 18 files changed, 12510 insertions(+) create mode 100644 packages/effect-acp/package.json create mode 100644 packages/effect-acp/scripts/generate.ts create mode 100644 packages/effect-acp/src/_generated/meta.gen.ts create mode 100644 packages/effect-acp/src/_generated/schema.gen.ts create mode 100644 packages/effect-acp/src/child-process.ts create mode 100644 packages/effect-acp/src/client.test.ts create mode 100644 packages/effect-acp/src/client.ts create mode 100644 packages/effect-acp/src/errors.ts create mode 100644 packages/effect-acp/src/protocol.test.ts create mode 100644 packages/effect-acp/src/protocol.ts create mode 100644 packages/effect-acp/src/rpc.ts create mode 100644 packages/effect-acp/src/server.ts create mode 100644 packages/effect-acp/src/terminal.ts create mode 100644 packages/effect-acp/test/examples/cursor-acp-client.example.ts create mode 100644 packages/effect-acp/test/fixtures/acp-mock-peer.ts create mode 100644 packages/effect-acp/tsconfig.json diff --git a/bun.lock b/bun.lock index 857d3a83c7e..308a815e34c 100644 --- a/bun.lock +++ b/bun.lock @@ -136,6 +136,21 @@ "vitest": "catalog:", }, }, + "packages/effect-acp": { + "name": "effect-acp", + "dependencies": { + "effect": "catalog:", + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "@effect/openapi-generator": "catalog:", + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "packages/shared": { "name": "@t3tools/shared", "version": "0.0.0-alpha.1", @@ -174,6 +189,7 @@ }, "catalog": { "@effect/language-service": "0.75.1", + "@effect/openapi-generator": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/openapi-generator@8881a9b", "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", @@ -267,6 +283,8 @@ "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], + "@effect/openapi-generator": ["@effect/openapi-generator@https://pkg.pr.new/Effect-TS/effect-smol/@effect/openapi-generator@8881a9b", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.25", "effect": "^4.0.0-beta.25" }, "bin": { "openapigen": "./dist/bin.js" } }], + "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25", "ioredis": "^5.7.0" } }], "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25" } }], @@ -1019,6 +1037,8 @@ "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], + "effect-acp": ["effect-acp@workspace:packages/effect-acp"], + "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], @@ -1905,6 +1925,10 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@effect/openapi-generator/@effect/platform-node": ["@effect/platform-node@4.0.0-beta.41", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.41", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.41", "ioredis": "^5.7.0" } }, "sha512-lgsWfvJfxwg7xXT5rK3xcPyAFPAYXXJ0u/6yCu2suOMhdR5w2W6oA2L2pIQaqap8qR8uEVB5pHq6dqVfdRP7Nw=="], + + "@effect/openapi-generator/effect": ["effect@4.0.0-beta.41", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-kBjbmo2qqXbOgrvZcPgVdgsOOWcGPYwRcvGO3aGPWJhpXxDFNfgtwqtU6asMq2M7LSFRx1SA+3BzJm7FDqtxew=="], + "@effect/platform-node/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], @@ -2003,6 +2027,8 @@ "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], + "@effect/openapi-generator/@effect/platform-node/@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.41", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.41" } }, "sha512-YzKWz1G8YofCrQ0Thxymlk71CM3q7R1vzO2vtnb7KSHkTJrvrQB8FbZaM8mEddYOoamR25f46lidFKErmmotKg=="], + "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], diff --git a/package.json b/package.json index 02e71cf097d..4d602ed10c9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", + "@effect/openapi-generator": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/openapi-generator@8881a9b", "@effect/language-service": "0.75.1", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json new file mode 100644 index 00000000000..c059c57cc3d --- /dev/null +++ b/packages/effect-acp/package.json @@ -0,0 +1,97 @@ +{ + "name": "effect-acp", + "private": true, + "type": "module", + "exports": { + "./client": { + "import": { + "types": "./src/client.d.mts", + "default": "./src/client.mjs" + }, + "require": { + "types": "./src/client.d.cts", + "default": "./src/client.cjs" + } + }, + "./server": { + "import": { + "types": "./src/server.d.mts", + "default": "./src/server.mjs" + }, + "require": { + "types": "./src/server.d.cts", + "default": "./src/server.cjs" + } + }, + "./schema": { + "import": { + "types": "./src/_generated/schema.gen.d.mts", + "default": "./src/_generated/schema.gen.mjs" + }, + "require": { + "types": "./src/_generated/schema.gen.d.cts", + "default": "./src/_generated/schema.gen.cjs" + } + }, + "./rpc": { + "import": { + "types": "./src/rpc.d.mts", + "default": "./src/rpc.mjs" + }, + "require": { + "types": "./src/rpc.d.cts", + "default": "./src/rpc.cjs" + } + }, + "./protocol": { + "import": { + "types": "./src/protocol.d.mts", + "default": "./src/protocol.mjs" + }, + "require": { + "types": "./src/protocol.d.cts", + "default": "./src/protocol.cjs" + } + }, + "./terminal": { + "import": { + "types": "./src/terminal.d.mts", + "default": "./src/terminal.mts" + }, + "require": { + "types": "./src/terminal.d.cts", + "default": "./src/terminal.cjs" + } + }, + "./child-process": { + "import": { + "types": "./src/child-process.d.mts", + "default": "./src/child-process.mts" + }, + "require": { + "types": "./src/child-process.d.cts", + "default": "./src/child-process.cjs" + } + } + }, + "scripts": { + "dev": "tsdown src/client.ts src/server.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/child-process.ts src/terminal.ts --format esm,cjs --dts --watch --clean", + "build": "tsdown src/client.ts src/server.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/child-process.ts src/terminal.ts --format esm,cjs --dts --clean", + "prepare": "effect-language-service patch", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "generate": "bun run scripts/generate.ts" + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "@effect/openapi-generator": "catalog:", + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts new file mode 100644 index 00000000000..09d43205dcd --- /dev/null +++ b/packages/effect-acp/scripts/generate.ts @@ -0,0 +1,269 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; +import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +const CURRENT_SCHEMA_RELEASE = "v0.11.3"; + +interface GeneratedPaths { + readonly generatedDir: string; + readonly upstreamSchemaPath: string; + readonly upstreamMetaPath: string; + readonly schemaOutputPath: string; + readonly metaOutputPath: string; +} + +const MetaJsonSchema = Schema.Struct({ + agentMethods: Schema.Record(Schema.String, Schema.String), + clientMethods: Schema.Record(Schema.String, Schema.String), + version: Schema.Union([Schema.Number, Schema.String]), +}); + +const UpstreamJsonSchemaSchema = Schema.Struct({ + $defs: Schema.Record(Schema.String, Schema.Json), +}); + +const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () { + const path = yield* Path.Path; + const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated"); + return { + generatedDir, + upstreamSchemaPath: path.join(generatedDir, "upstream-schema.json"), + upstreamMetaPath: path.join(generatedDir, "upstream-meta.json"), + schemaOutputPath: path.join(generatedDir, "schema.gen.ts"), + metaOutputPath: path.join(generatedDir, "meta.gen.ts"), + } satisfies GeneratedPaths; +}); + +const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () { + const fs = yield* FileSystem.FileSystem; + const { generatedDir } = yield* getGeneratedPaths(); + + yield* fs.makeDirectory(generatedDir, { recursive: true }); +}); + +const downloadFile = Effect.fn("downloadFile")(function* (url: string, outputPath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true }); + + const text = yield* HttpClient.get(url).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.text), + ); + + yield* fs.writeFileString(outputPath, text); +}); + +const downloadSchemas = Effect.fn("downloadSchemas")(function* (tag: string) { + const { upstreamMetaPath, upstreamSchemaPath } = yield* getGeneratedPaths(); + const fs = yield* FileSystem.FileSystem; + const baseUrl = `https://github.com/agentclientprotocol/agent-client-protocol/releases/download/${tag}`; + + yield* downloadFile(`${baseUrl}/schema.unstable.json`, upstreamSchemaPath); + yield* downloadFile(`${baseUrl}/meta.unstable.json`, upstreamMetaPath); + + yield* Effect.addFinalizer(() => + Effect.all([fs.remove(upstreamSchemaPath), fs.remove(upstreamMetaPath)]).pipe( + Effect.ignoreCause({ log: true }), + ), + ); +}); + +const readJsonFile = Effect.fn("readJsonFile")(function* < + S extends Schema.Top & { readonly DecodingServices: never }, +>(schema: S, filePath: string) { + const fs = yield* FileSystem.FileSystem; + const raw = yield* fs.readFileString(filePath); + return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(raw); +}); + +const writeGeneratedFiles = Effect.fn("writeGeneratedFiles")(function* ( + schemaOutput: string, + metaOutput: string, +) { + const fs = yield* FileSystem.FileSystem; + const { metaOutputPath, schemaOutputPath } = yield* getGeneratedPaths(); + + yield* fs.writeFileString(schemaOutputPath, schemaOutput); + yield* fs.writeFileString(metaOutputPath, metaOutput); +}); + +function collectSchemaEntries( + chunk: string, +): ReadonlyArray<{ readonly name: string; readonly code: string }> { + const lines = chunk + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("//")); + const entries: Array<{ name: string; code: string }> = []; + + for (let index = 0; index < lines.length; index += 1) { + const typeLine = lines[index]; + if (!typeLine?.startsWith("export type ")) { + continue; + } + + const constLine = lines[index + 1]; + if (!constLine?.startsWith("export const ")) { + throw new Error(`Malformed generator output near: ${typeLine}`); + } + + const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine); + if (!match?.[1]) { + throw new Error(`Could not extract schema name from: ${typeLine}`); + } + + entries.push({ + name: match[1], + code: `${typeLine}\n${constLine}`, + }); + index += 1; + } + + return entries; +} + +function normalizeNullableTypes(value: typeof Schema.Json.Type): typeof Schema.Json.Type { + if (Array.isArray(value)) { + return value.map(normalizeNullableTypes); + } + if (value === null || typeof value !== "object") { + return value; + } + + const normalizedEntries = Object.entries(value).map(([key, child]) => [ + key, + normalizeNullableTypes(child), + ]); + const normalizedObject = Object.fromEntries(normalizedEntries) as Record< + string, + typeof Schema.Json.Type + >; + const typeValue = normalizedObject.type; + + if (!Array.isArray(typeValue)) { + return normalizedObject; + } + + const normalizedTypes = typeValue.filter((entry): entry is string => typeof entry === "string"); + if (normalizedTypes.length !== typeValue.length || !normalizedTypes.includes("null")) { + return normalizedObject; + } + + const nonNullTypes = normalizedTypes.filter((entry) => entry !== "null"); + if (nonNullTypes.length !== 1) { + return normalizedObject; + } + const nonNullType = nonNullTypes[0]!; + + const nextObject: Record = {}; + for (const [key, child] of Object.entries(normalizedObject)) { + if (key !== "type") { + nextObject[key] = child; + } + } + + return { + anyOf: [ + { + ...nextObject, + type: nonNullType, + }, + { type: "null" }, + ], + }; +} + +const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: boolean) { + const { upstreamMetaPath, upstreamSchemaPath } = yield* getGeneratedPaths(); + + yield* ensureGeneratedDir(); + + if (!skipDownload) { + yield* Effect.log(`Downloading ACP schema assets for ${CURRENT_SCHEMA_RELEASE}`); + yield* downloadSchemas(CURRENT_SCHEMA_RELEASE); + } + + const upstreamSchema = yield* readJsonFile(UpstreamJsonSchemaSchema, upstreamSchemaPath); + const upstreamMeta = yield* readJsonFile(MetaJsonSchema, upstreamMetaPath); + const normalizedDefinitions = Object.fromEntries( + Object.entries(upstreamSchema.$defs).map(([name, schema]) => [ + name, + normalizeNullableTypes(schema), + ]), + ); + + const sortedEntries = Object.entries(normalizedDefinitions).toSorted(([left], [right]) => + left.localeCompare(right), + ); + const generatedEntries = new Map(); + const generator = makeJsonSchemaGenerator(); + + for (const [name, schema] of sortedEntries) { + generator.addSchema(name, schema as never); + } + + const output = generator.generate("openapi-3.1", normalizedDefinitions as never, false).trim(); + if (output.length > 0) { + for (const entry of collectSchemaEntries(output)) { + if (!generatedEntries.has(entry.name)) { + generatedEntries.set(entry.name, entry.code); + } + } + } + + const schemaOutput = [ + 'import * as Schema from "effect/Schema";', + "", + [...generatedEntries.values()].join("\n\n"), + "", + ].join("\n"); + + const metaOutput = [ + `export const AGENT_METHODS = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.agentMethods))(upstreamMeta.agentMethods)} as const;`, + "", + `export const CLIENT_METHODS = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.clientMethods))(upstreamMeta.clientMethods)} as const;`, + "", + `export const PROTOCOL_VERSION = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.version))(upstreamMeta.version)} as const;`, + "", + ].join("\n"); + + yield* writeGeneratedFiles(schemaOutput, metaOutput); + yield* Effect.log( + `Generated ${generatedEntries.size} ACP schemas from ${CURRENT_SCHEMA_RELEASE}`, + ); + + const { generatedDir } = yield* getGeneratedPaths(); + yield* Effect.service(ChildProcessSpawner.ChildProcessSpawner).pipe( + Effect.flatMap((spawner) => spawner.spawn(ChildProcess.make("bun", ["oxfmt", generatedDir]))), + Effect.flatMap((child) => child.exitCode), + Effect.tap(() => Effect.log("Formatted generated files")), + ); +}); + +const generateCommand = Command.make( + "generate", + { + skipDownload: Flag.boolean("skip-download").pipe(Flag.withDefault(false)), + }, + ({ skipDownload }) => generateSchemas(skipDownload), +).pipe(Command.withDescription("Generate Effect ACP schemas from the pinned ACP release assets.")); + +const runtimeLayer = Layer.mergeAll( + Logger.layer([Logger.consolePretty()]), + NodeServices.layer, + FetchHttpClient.layer, +); + +Command.run(generateCommand, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(runtimeLayer), + NodeRuntime.runMain, +); diff --git a/packages/effect-acp/src/_generated/meta.gen.ts b/packages/effect-acp/src/_generated/meta.gen.ts new file mode 100644 index 00000000000..b1b719a2024 --- /dev/null +++ b/packages/effect-acp/src/_generated/meta.gen.ts @@ -0,0 +1,32 @@ +export const AGENT_METHODS = { + authenticate: "authenticate", + initialize: "initialize", + logout: "logout", + session_cancel: "session/cancel", + session_close: "session/close", + session_fork: "session/fork", + session_list: "session/list", + session_load: "session/load", + session_new: "session/new", + session_prompt: "session/prompt", + session_resume: "session/resume", + session_set_config_option: "session/set_config_option", + session_set_mode: "session/set_mode", + session_set_model: "session/set_model", +} as const; + +export const CLIENT_METHODS = { + fs_read_text_file: "fs/read_text_file", + fs_write_text_file: "fs/write_text_file", + session_elicitation: "session/elicitation", + session_elicitation_complete: "session/elicitation/complete", + session_request_permission: "session/request_permission", + session_update: "session/update", + terminal_create: "terminal/create", + terminal_kill: "terminal/kill", + terminal_output: "terminal/output", + terminal_release: "terminal/release", + terminal_wait_for_exit: "terminal/wait_for_exit", +} as const; + +export const PROTOCOL_VERSION = 1 as const; diff --git a/packages/effect-acp/src/_generated/schema.gen.ts b/packages/effect-acp/src/_generated/schema.gen.ts new file mode 100644 index 00000000000..3a86373fff2 --- /dev/null +++ b/packages/effect-acp/src/_generated/schema.gen.ts @@ -0,0 +1,10156 @@ +import * as Schema from "effect/Schema"; + +export type AuthEnvVar = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly label?: string | null; + readonly name: string; + readonly optional?: boolean; + readonly secret?: boolean; +}; +export const AuthEnvVar = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + label: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Human-readable label for this variable, displayed in client UI.", + }), + Schema.Null, + ]), + ), + name: Schema.String.annotate({ + description: 'The environment variable name (e.g. `"OPENAI_API_KEY"`).', + }), + optional: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether this variable is optional.\n\nDefaults to `false`.", + default: false, + }), + ), + secret: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether this value is a secret (e.g. API key, token).\nClients should use a password-style input for secret vars.\n\nDefaults to `true`.", + default: true, + }), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nDescribes a single environment variable for an [`AuthMethodEnvVar`] authentication method.", +}); + +export type AvailableCommandInput = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly hint: string; +}; +export const AvailableCommandInput = Schema.Union([ + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + hint: Schema.String.annotate({ + description: "A hint to display when the input hasn't been provided yet", + }), + }).annotate({ + title: "unstructured", + description: "All text that was typed after the command name is provided as input.", + }), +]).annotate({ description: "The input specification for a command." }); + +export type Cost = { readonly amount: number; readonly currency: string }; +export const Cost = Schema.Struct({ + amount: Schema.Number.annotate({ + description: "Total cumulative cost for session.", + format: "double", + }).check(Schema.isFinite()), + currency: Schema.String.annotate({ description: 'ISO 4217 currency code (e.g., "USD", "EUR").' }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCost information for a session.", +}); + +export type ElicitationContentValue = string | number | number | boolean | ReadonlyArray; +export const ElicitationContentValue = Schema.Union([ + Schema.String.annotate({ title: "String" }), + Schema.Number.annotate({ title: "Integer", format: "int64" }).check(Schema.isInt()), + Schema.Number.annotate({ title: "Number", format: "double" }).check(Schema.isFinite()), + Schema.Boolean.annotate({ title: "Boolean" }), + Schema.Array(Schema.String).annotate({ title: "StringArray" }), +]); + +export type ElicitationFormCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; +}; +export const ElicitationFormCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation capabilities.", +}); + +export type ElicitationUrlCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; +}; +export const ElicitationUrlCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation capabilities.", +}); + +export type EmbeddedResourceResource = + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly mimeType?: string | null; + readonly text: string; + readonly uri: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly blob: string; + readonly mimeType?: string | null; + readonly uri: string; + }; +export const EmbeddedResourceResource = Schema.Union([ + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + text: Schema.String, + uri: Schema.String, + }).annotate({ title: "TextResourceContents", description: "Text-based resource contents." }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + blob: Schema.String, + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ title: "BlobResourceContents", description: "Binary resource contents." }), +]).annotate({ description: "Resource content that can be embedded in a message." }); + +export type EnumOption = { readonly const: string; readonly title: string }; +export const EnumOption = Schema.Struct({ + const: Schema.String.annotate({ description: "The constant value for this option." }), + title: Schema.String.annotate({ description: "Human-readable title for this option." }), +}).annotate({ description: "A titled enum option with a const value and human-readable title." }); + +export type EnvVariable = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly name: string; + readonly value: string; +}; +export const EnvVariable = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + name: Schema.String.annotate({ description: "The name of the environment variable." }), + value: Schema.String.annotate({ description: "The value to set for the environment variable." }), +}).annotate({ description: "An environment variable to set when launching an MCP server." }); + +export type Error = { + readonly code: + | -32700 + | -32600 + | -32601 + | -32602 + | -32603 + | -32800 + | -32000 + | -32002 + | -32042 + | number; + readonly data?: unknown; + readonly message: string; +}; +export const Error = Schema.Struct({ + code: Schema.Union([ + Schema.Literal(-32700).annotate({ + title: "Parse error", + description: + "**Parse error**: Invalid JSON was received by the server.\nAn error occurred on the server while parsing the JSON text.", + format: "int32", + }), + Schema.Literal(-32600).annotate({ + title: "Invalid request", + description: "**Invalid request**: The JSON sent is not a valid Request object.", + format: "int32", + }), + Schema.Literal(-32601).annotate({ + title: "Method not found", + description: "**Method not found**: The method does not exist or is not available.", + format: "int32", + }), + Schema.Literal(-32602).annotate({ + title: "Invalid params", + description: "**Invalid params**: Invalid method parameter(s).", + format: "int32", + }), + Schema.Literal(-32603).annotate({ + title: "Internal error", + description: + "**Internal error**: Internal JSON-RPC error.\nReserved for implementation-defined server errors.", + format: "int32", + }), + Schema.Literal(-32800).annotate({ + title: "Request cancelled", + description: + "**Request cancelled**: **UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nExecution of the method was aborted either due to a cancellation request from the caller or\nbecause of resource constraints or shutdown.", + format: "int32", + }), + Schema.Literal(-32000).annotate({ + title: "Authentication required", + description: + "**Authentication required**: Authentication is required before this operation can be performed.", + format: "int32", + }), + Schema.Literal(-32002).annotate({ + title: "Resource not found", + description: "**Resource not found**: A given resource, such as a file, was not found.", + format: "int32", + }), + Schema.Literal(-32042).annotate({ + title: "URL elicitation required", + description: + "**URL elicitation required**: **UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe agent requires user input via a URL-based elicitation before it can proceed.", + format: "int32", + }), + Schema.Number.annotate({ + title: "Other", + description: "Other undefined error code.", + format: "int32", + }).check(Schema.isInt()), + ]).annotate({ + description: + "Predefined error codes for common JSON-RPC and ACP-specific errors.\n\nThese codes follow the JSON-RPC 2.0 specification for standard errors\nand use the reserved range (-32000 to -32099) for protocol-specific errors.", + }), + data: Schema.optionalKey( + Schema.Unknown.annotate({ + description: + "Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details.", + }), + ), + message: Schema.String.annotate({ + description: + "A string providing a short description of the error.\nThe message should be limited to a concise single sentence.", + }), +}).annotate({ + description: + "JSON-RPC error object.\n\nRepresents an error that occurred during method execution, following the\nJSON-RPC 2.0 error object specification with optional additional data.\n\nSee protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object)", +}); + +export type HttpHeader = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly name: string; + readonly value: string; +}; +export const HttpHeader = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + name: Schema.String.annotate({ description: "The name of the HTTP header." }), + value: Schema.String.annotate({ description: "The value to set for the HTTP header." }), +}).annotate({ description: "An HTTP header to set when making requests to the MCP server." }); + +export type Implementation = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly name: string; + readonly title?: string | null; + readonly version: string; +}; +export const Implementation = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + name: Schema.String.annotate({ + description: + "Intended for programmatic or logical use, but can be used as a display\nname fallback if title isn’t present.", + }), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Intended for UI and end-user contexts — optimized to be human-readable\nand easily understood.\n\nIf not provided, the name should be used for display.", + }), + Schema.Null, + ]), + ), + version: Schema.String.annotate({ + description: + 'Version of the implementation. Can be displayed to the user or used\nfor debugging or metrics purposes. (e.g. "1.0.0").', + }), +}).annotate({ + description: + "Metadata about the implementation of the client or agent.\nDescribes the name and version of an MCP implementation, with an optional\ntitle for UI representation.", +}); + +export type LogoutCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const LogoutCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nLogout capabilities supported by the agent.\n\nBy supplying `{}` it means that the agent supports the logout method.", +}); + +export type ModelInfo = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly description?: string | null; + readonly modelId: string; + readonly name: string; +}; +export const ModelInfo = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional description of the model." }), + Schema.Null, + ]), + ), + modelId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for a model.", + }), + name: Schema.String.annotate({ description: "Human-readable name of the model." }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInformation about a selectable model.", +}); + +export type PermissionOption = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly kind: "allow_once" | "allow_always" | "reject_once" | "reject_always"; + readonly name: string; + readonly optionId: string; +}; +export const PermissionOption = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + kind: Schema.Literals(["allow_once", "allow_always", "reject_once", "reject_always"]).annotate({ + description: + "The type of permission option being presented to the user.\n\nHelps clients choose appropriate icons and UI treatment.", + }), + name: Schema.String.annotate({ description: "Human-readable label to display to the user." }), + optionId: Schema.String.annotate({ description: "Unique identifier for a permission option." }), +}).annotate({ description: "An option presented to the user when requesting permission." }); + +export type PlanEntry = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: string; + readonly priority: "high" | "medium" | "low"; + readonly status: "pending" | "in_progress" | "completed"; +}; +export const PlanEntry = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.String.annotate({ + description: "Human-readable description of what this task aims to accomplish.", + }), + priority: Schema.Literals(["high", "medium", "low"]).annotate({ + description: + "Priority levels for plan entries.\n\nUsed to indicate the relative importance or urgency of different\ntasks in the execution plan.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + }), + status: Schema.Literals(["pending", "in_progress", "completed"]).annotate({ + description: + "Status of a plan entry in the execution flow.\n\nTracks the lifecycle of each task from planning through completion.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + }), +}).annotate({ + description: + "A single entry in the execution plan.\n\nRepresents a task or goal that the assistant intends to accomplish\nas part of fulfilling the user's request.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", +}); + +export type RequestId = null | number | string; +export const RequestId = Schema.Union([ + Schema.Null.annotate({ title: "Null" }), + Schema.Number.annotate({ title: "Number", format: "int64" }).check(Schema.isInt()), + Schema.String.annotate({ title: "Str" }), +]).annotate({ + description: + "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions.", +}); + +export type Role = "assistant" | "user"; +export const Role = Schema.Literals(["assistant", "user"]).annotate({ + description: "The sender or recipient of messages and data in a conversation.", +}); + +export type SessionCloseCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; +}; +export const SessionCloseCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/close` method.\n\nBy supplying `{}` it means that the agent supports closing of sessions.", +}); + +export type SessionConfigSelectOption = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly description?: string | null; + readonly name: string; + readonly value: string; +}; +export const SessionConfigSelectOption = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional description for this option value." }), + Schema.Null, + ]), + ), + name: Schema.String.annotate({ description: "Human-readable label for this option value." }), + value: Schema.String.annotate({ + description: "Unique identifier for a session configuration option value.", + }), +}).annotate({ description: "A possible value for a session configuration option." }); + +export type SessionForkCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const SessionForkCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/fork` method.\n\nBy supplying `{}` it means that the agent supports forking of sessions.", +}); + +export type SessionInfo = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cwd: string; + readonly sessionId: string; + readonly title?: string | null; + readonly updatedAt?: string | null; +}; +export const SessionInfo = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cwd: Schema.String.annotate({ + description: "The working directory for this session. Must be an absolute path.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable title for the session" }), + Schema.Null, + ]), + ), + updatedAt: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "ISO 8601 timestamp of last activity" }), + Schema.Null, + ]), + ), +}).annotate({ description: "Information about a session returned by session/list" }); + +export type SessionListCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const SessionListCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "Capabilities for the `session/list` method.\n\nBy supplying `{}` it means that the agent supports listing of sessions.", +}); + +export type SessionModeId = string; +export const SessionModeId = Schema.String.annotate({ + description: "Unique identifier for a Session Mode.", +}); + +export type SessionResumeCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; +}; +export const SessionResumeCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/resume` method.\n\nBy supplying `{}` it means that the agent supports resuming of sessions.", +}); + +export type StringFormat = "email" | "uri" | "date" | "date-time"; +export const StringFormat = Schema.Literals(["email", "uri", "date", "date-time"]).annotate({ + description: "String format types for string properties in elicitation schemas.", +}); + +export type TerminalExitStatus = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly exitCode?: number | null; + readonly signal?: string | null; +}; +export const TerminalExitStatus = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + exitCode: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "The process exit code (may be null if terminated by signal).", + format: "uint32", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + signal: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "The signal that terminated the process (may be null if exited normally).", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Exit status of a terminal command." }); + +export type ToolCallLocation = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly line?: number | null; + readonly path: string; +}; +export const ToolCallLocation = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + line: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Optional line number within the file.", + format: "uint32", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + path: Schema.String.annotate({ description: "The file path being accessed or modified." }), +}).annotate({ + description: + 'A file location being accessed or modified by a tool.\n\nEnables clients to implement "follow-along" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)', +}); + +export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed"; +export const ToolCallStatus = Schema.Literals([ + "pending", + "in_progress", + "completed", + "failed", +]).annotate({ + description: + "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)", +}); + +export type ToolKind = + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; +export const ToolKind = Schema.Literals([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", +]).annotate({ + description: + "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)", +}); + +export type Usage = { + readonly cachedReadTokens?: number | null; + readonly cachedWriteTokens?: number | null; + readonly inputTokens: number; + readonly outputTokens: number; + readonly thoughtTokens?: number | null; + readonly totalTokens: number; +}; +export const Usage = Schema.Struct({ + cachedReadTokens: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Total cache read tokens.", format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + cachedWriteTokens: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Total cache write tokens.", format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + inputTokens: Schema.Number.annotate({ + description: "Total input tokens across all turns.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + outputTokens: Schema.Number.annotate({ + description: "Total output tokens across all turns.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + thoughtTokens: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Total thought/reasoning tokens", format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + totalTokens: Schema.Number.annotate({ + description: "Sum of all token types across session.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nToken usage information for a prompt turn.", +}); + +export type AuthMethod = + | { + readonly type: "env_var"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly description?: string | null; + readonly id: string; + readonly link?: string | null; + readonly name: string; + readonly vars: ReadonlyArray; + } + | { + readonly type: "terminal"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly args?: ReadonlyArray; + readonly description?: string | null; + readonly env?: { readonly [x: string]: string }; + readonly id: string; + readonly name: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly description?: string | null; + readonly id: string; + readonly name: string; + }; +export const AuthMethod = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("env_var"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional description providing more details about this authentication method.", + }), + Schema.Null, + ]), + ), + id: Schema.String.annotate({ + description: "Unique identifier for this authentication method.", + }), + link: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional link to a page where the user can obtain their credentials.", + }), + Schema.Null, + ]), + ), + name: Schema.String.annotate({ + description: "Human-readable name of the authentication method.", + }), + vars: Schema.Array(AuthEnvVar).annotate({ + description: "The environment variables the client should set.", + }), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nEnvironment variable authentication method.\n\nThe user provides credentials that the client passes to the agent as environment variables.", + }), + Schema.Struct({ + type: Schema.Literal("terminal"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + args: Schema.optionalKey( + Schema.Array(Schema.String).annotate({ + description: + "Additional arguments to pass when running the agent binary for terminal auth.", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional description providing more details about this authentication method.", + }), + Schema.Null, + ]), + ), + env: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional environment variables to set when running the agent binary for terminal auth.", + }), + ), + id: Schema.String.annotate({ + description: "Unique identifier for this authentication method.", + }), + name: Schema.String.annotate({ + description: "Human-readable name of the authentication method.", + }), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nTerminal-based authentication method.\n\nThe client runs an interactive terminal for the user to authenticate via a TUI.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional description providing more details about this authentication method.", + }), + Schema.Null, + ]), + ), + id: Schema.String.annotate({ + description: "Unique identifier for this authentication method.", + }), + name: Schema.String.annotate({ + description: "Human-readable name of the authentication method.", + }), + }).annotate({ + title: "agent", + description: + "Agent handles authentication itself.\n\nThis is the default authentication method type.", + }), +]).annotate({ + description: + "Describes an available authentication method.\n\nThe `type` field acts as the discriminator in the serialized JSON form.\nWhen no `type` is present, the method is treated as `agent`.", +}); + +export type AvailableCommand = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly description: string; + readonly input?: AvailableCommandInput | null; + readonly name: string; +}; +export const AvailableCommand = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + description: Schema.String.annotate({ + description: "Human-readable description of what the command does.", + }), + input: Schema.optionalKey( + Schema.Union([AvailableCommandInput, Schema.Null]).annotate({ + description: "Input for the command if required", + }), + ), + name: Schema.String.annotate({ + description: "Command name (e.g., `create_plan`, `research_codebase`).", + }), +}).annotate({ description: "Information about a command." }); + +export type ElicitationCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly form?: ElicitationFormCapabilities | null; + readonly url?: ElicitationUrlCapabilities | null; +}; +export const ElicitationCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + form: Schema.optionalKey( + Schema.Union([ElicitationFormCapabilities, Schema.Null]).annotate({ + description: "Whether the client supports form-based elicitation.", + }), + ), + url: Schema.optionalKey( + Schema.Union([ElicitationUrlCapabilities, Schema.Null]).annotate({ + description: "Whether the client supports URL-based elicitation.", + }), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.", +}); + +export type McpServer = + | { + readonly type: "http"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly headers: ReadonlyArray; + readonly name: string; + readonly url: string; + } + | { + readonly type: "sse"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly headers: ReadonlyArray; + readonly name: string; + readonly url: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly args: ReadonlyArray; + readonly command: string; + readonly env: ReadonlyArray; + readonly name: string; + }; +export const McpServer = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("http"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + headers: Schema.Array(HttpHeader).annotate({ + description: "HTTP headers to set when making requests to the MCP server.", + }), + name: Schema.String.annotate({ + description: "Human-readable name identifying this MCP server.", + }), + url: Schema.String.annotate({ description: "URL to the MCP server." }), + }).annotate({ description: "HTTP transport configuration for MCP." }), + Schema.Struct({ + type: Schema.Literal("sse"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + headers: Schema.Array(HttpHeader).annotate({ + description: "HTTP headers to set when making requests to the MCP server.", + }), + name: Schema.String.annotate({ + description: "Human-readable name identifying this MCP server.", + }), + url: Schema.String.annotate({ description: "URL to the MCP server." }), + }).annotate({ description: "SSE transport configuration for MCP." }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + args: Schema.Array(Schema.String).annotate({ + description: "Command-line arguments to pass to the MCP server.", + }), + command: Schema.String.annotate({ description: "Path to the MCP server executable." }), + env: Schema.Array(EnvVariable).annotate({ + description: "Environment variables to set when launching the MCP server.", + }), + name: Schema.String.annotate({ + description: "Human-readable name identifying this MCP server.", + }), + }).annotate({ title: "stdio", description: "Stdio transport configuration for MCP." }), +]).annotate({ + description: + "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)", +}); + +export type SessionModelState = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly availableModels: ReadonlyArray; + readonly currentModelId: string; +}; +export const SessionModelState = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + availableModels: Schema.Array(ModelInfo).annotate({ + description: "The set of models that the Agent can use", + }), + currentModelId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for a model.", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe set of models and the one currently active.", +}); + +export type Annotations = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly audience?: ReadonlyArray | null; + readonly lastModified?: string | null; + readonly priority?: number | null; +}; +export const Annotations = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + audience: Schema.optionalKey(Schema.Union([Schema.Array(Role), Schema.Null])), + lastModified: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + priority: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "double" }).check(Schema.isFinite()), + Schema.Null, + ]), + ), +}).annotate({ + description: + "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", +}); + +export type SessionConfigSelectGroup = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly group: string; + readonly name: string; + readonly options: ReadonlyArray; +}; +export const SessionConfigSelectGroup = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + group: Schema.String.annotate({ + description: "Unique identifier for a session configuration option value group.", + }), + name: Schema.String.annotate({ description: "Human-readable label for this group." }), + options: Schema.Array(SessionConfigSelectOption).annotate({ + description: "The set of option values in this group.", + }), +}).annotate({ description: "A group of possible values for a session configuration option." }); + +export type SessionMode = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly description?: string | null; + readonly id: SessionModeId; + readonly name: string; +}; +export const SessionMode = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + id: SessionModeId, + name: Schema.String, +}).annotate({ + description: + "A mode the agent can operate in.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", +}); + +export type ElicitationPropertySchema = + | { + readonly type: "string"; + readonly default?: string | null; + readonly description?: string | null; + readonly enum?: ReadonlyArray | null; + readonly format?: StringFormat | null; + readonly maxLength?: number | null; + readonly minLength?: number | null; + readonly oneOf?: ReadonlyArray | null; + readonly pattern?: string | null; + readonly title?: string | null; + } + | { + readonly type: "number"; + readonly default?: number | null; + readonly description?: string | null; + readonly maximum?: number | null; + readonly minimum?: number | null; + readonly title?: string | null; + } + | { + readonly type: "integer"; + readonly default?: number | null; + readonly description?: string | null; + readonly maximum?: number | null; + readonly minimum?: number | null; + readonly title?: string | null; + } + | { + readonly type: "boolean"; + readonly default?: boolean | null; + readonly description?: string | null; + readonly title?: string | null; + } + | { + readonly type: "array"; + readonly default?: ReadonlyArray | null; + readonly description?: string | null; + readonly items: + | { readonly enum: ReadonlyArray; readonly type: "string" } + | { readonly anyOf: ReadonlyArray }; + readonly maxItems?: number | null; + readonly minItems?: number | null; + readonly title?: string | null; + }; +export const ElicitationPropertySchema = Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("string"), + default: Schema.optionalKey( + Schema.Union([Schema.String.annotate({ description: "Default value." }), Schema.Null]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable description." }), + Schema.Null, + ]), + ), + enum: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.String).annotate({ + description: "Enum values for untitled single-select enums.", + }), + Schema.Null, + ]), + ), + format: Schema.optionalKey( + Schema.Union([StringFormat, Schema.Null]).annotate({ description: "String format." }), + ), + maxLength: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Maximum string length.", format: "uint32" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + minLength: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Minimum string length.", format: "uint32" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + oneOf: Schema.optionalKey( + Schema.Union([ + Schema.Array(EnumOption).annotate({ + description: "Titled enum options for titled single-select enums.", + }), + Schema.Null, + ]), + ), + pattern: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Pattern the string must match." }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the property." }), + Schema.Null, + ]), + ), + }).annotate({ + description: + 'Schema for string properties in an elicitation form.\n\nWhen `enum` or `oneOf` is set, this represents a single-select enum\nwith `"type": "string"`.', + }), + Schema.Struct({ + type: Schema.Literal("number"), + default: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Default value.", format: "double" }).check( + Schema.isFinite(), + ), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable description." }), + Schema.Null, + ]), + ), + maximum: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Maximum value (inclusive).", + format: "double", + }).check(Schema.isFinite()), + Schema.Null, + ]), + ), + minimum: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Minimum value (inclusive).", + format: "double", + }).check(Schema.isFinite()), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the property." }), + Schema.Null, + ]), + ), + }).annotate({ + description: "Schema for number (floating-point) properties in an elicitation form.", + }), + Schema.Struct({ + type: Schema.Literal("integer"), + default: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Default value.", format: "int64" }).check( + Schema.isInt(), + ), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable description." }), + Schema.Null, + ]), + ), + maximum: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Maximum value (inclusive).", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), + minimum: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Minimum value (inclusive).", + format: "int64", + }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the property." }), + Schema.Null, + ]), + ), + }).annotate({ description: "Schema for integer properties in an elicitation form." }), + Schema.Struct({ + type: Schema.Literal("boolean"), + default: Schema.optionalKey( + Schema.Union([Schema.Boolean.annotate({ description: "Default value." }), Schema.Null]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable description." }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the property." }), + Schema.Null, + ]), + ), + }).annotate({ description: "Schema for boolean properties in an elicitation form." }), + Schema.Struct({ + type: Schema.Literal("array"), + default: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.String).annotate({ description: "Default selected values." }), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable description." }), + Schema.Null, + ]), + ), + items: Schema.Union([ + Schema.Struct({ + enum: Schema.Array(Schema.String).annotate({ description: "Allowed enum values." }), + type: Schema.Literal("string").annotate({ + description: "Items definition for untitled multi-select enum properties.", + }), + }).annotate({ + title: "Untitled", + description: "Items definition for untitled multi-select enum properties.", + }), + Schema.Struct({ + anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }), + }).annotate({ + title: "Titled", + description: "Items definition for titled multi-select enum properties.", + }), + ]).annotate({ description: "Items for a multi-select (array) property schema." }), + maxItems: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Maximum number of items to select.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + minItems: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Minimum number of items to select.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the property." }), + Schema.Null, + ]), + ), + }).annotate({ + description: "Schema for multi-select (array) properties in an elicitation form.", + }), + ], + { mode: "oneOf" }, +).annotate({ + description: + 'Property schema for elicitation form fields.\n\nEach variant corresponds to a JSON Schema `"type"` value.\nSingle-select enums use the `String` variant with `enum` or `oneOf` set.\nMulti-select enums use the `Array` variant.', +}); + +export type ContentBlock = + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; +export const ContentBlock = Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", +}); + +export type ToolCallContent = + | { + readonly type: "content"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + } + | { + readonly type: "diff"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly newText: string; + readonly oldText?: string | null; + readonly path: string; + } + | { + readonly type: "terminal"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly terminalId: string; + }; +export const ToolCallContent = Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("content"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + }).annotate({ description: "Standard content block (text, images, resources)." }), + Schema.Struct({ + type: Schema.Literal("diff"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + newText: Schema.String.annotate({ description: "The new content after modification." }), + oldText: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "The original content (None for new files)." }), + Schema.Null, + ]), + ), + path: Schema.String.annotate({ description: "The file path being modified." }), + }).annotate({ + description: + "A diff representing file modifications.\n\nShows changes to files in a format suitable for display in the client UI.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", + }), + Schema.Struct({ + type: Schema.Literal("terminal"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + terminalId: Schema.String, + }).annotate({ + description: + "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)", + }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Content produced by a tool call.\n\nTool calls can produce different types of content including\nstandard content blocks (text, images) or file diffs.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", +}); + +export type SessionConfigOption = + | { + readonly type: "select"; + readonly currentValue: string; + readonly options: + | ReadonlyArray + | ReadonlyArray; + } + | { readonly type: "boolean"; readonly currentValue: boolean }; +export const SessionConfigOption = Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("select"), + currentValue: Schema.String.annotate({ + description: "Unique identifier for a session configuration option value.", + }), + options: Schema.Union([ + Schema.Array(SessionConfigSelectOption).annotate({ + title: "Ungrouped", + description: "A flat list of options with no grouping.", + }), + Schema.Array(SessionConfigSelectGroup).annotate({ + title: "Grouped", + description: "A list of options grouped under headers.", + }), + ]).annotate({ description: "Possible values for a session configuration option." }), + }).annotate({ + description: "A single-value selector (dropdown) session configuration option payload.", + }), + Schema.Struct({ + type: Schema.Literal("boolean"), + currentValue: Schema.Boolean.annotate({ + description: "The current value of the boolean option.", + }), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA boolean on/off toggle session configuration option payload.", + }), + ], + { mode: "oneOf" }, +).annotate({ description: "A session configuration option selector and its current state." }); + +export type SessionModeState = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly availableModes: ReadonlyArray; + readonly currentModeId: string; +}; +export const SessionModeState = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + availableModes: Schema.Array(SessionMode).annotate({ + description: "The set of modes that the Agent can operate in", + }), + currentModeId: Schema.String.annotate({ description: "Unique identifier for a Session Mode." }), +}).annotate({ description: "The set of modes and the one currently active." }); + +export type AgentAuthCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly logout?: LogoutCapabilities | null; +}; +export const AgentAuthCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + logout: Schema.optionalKey( + Schema.Union([LogoutCapabilities, Schema.Null]).annotate({ + description: + "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.", + }), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication-related capabilities supported by the agent.", +}); + +export type AgentCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly auth?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly logout?: LogoutCapabilities | null; + }; + readonly loadSession?: boolean; + readonly mcpCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly http?: boolean; + readonly sse?: boolean; + }; + readonly promptCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly audio?: boolean; + readonly embeddedContext?: boolean; + readonly image?: boolean; + }; + readonly sessionCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly close?: SessionCloseCapabilities | null; + readonly fork?: SessionForkCapabilities | null; + readonly list?: SessionListCapabilities | null; + readonly resume?: SessionResumeCapabilities | null; + }; +}; +export const AgentCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + auth: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + logout: Schema.optionalKey( + Schema.Union([LogoutCapabilities, Schema.Null]).annotate({ + description: + "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.", + }), + ), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication-related capabilities supported by the agent.", + default: {}, + }), + ), + loadSession: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the agent supports `session/load`.", + default: false, + }), + ), + mcpCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + http: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`McpServer::Http`].", + default: false, + }), + ), + sse: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`McpServer::Sse`].", + default: false, + }), + ), + }).annotate({ + description: "MCP capabilities supported by the agent", + default: { http: false, sse: false }, + }), + ), + promptCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + audio: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`ContentBlock::Audio`].", + default: false, + }), + ), + embeddedContext: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.", + default: false, + }), + ), + image: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`ContentBlock::Image`].", + default: false, + }), + ), + }).annotate({ + description: + "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)", + default: { audio: false, embeddedContext: false, image: false }, + }), + ), + sessionCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + close: Schema.optionalKey( + Schema.Union([SessionCloseCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/close`.", + }), + ), + fork: Schema.optionalKey( + Schema.Union([SessionForkCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.", + }), + ), + list: Schema.optionalKey( + Schema.Union([SessionListCapabilities, Schema.Null]).annotate({ + description: "Whether the agent supports `session/list`.", + }), + ), + resume: Schema.optionalKey( + Schema.Union([SessionResumeCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/resume`.", + }), + ), + }).annotate({ + default: {}, + description: + "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)", + }), + ), +}).annotate({ + description: + "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)", +}); + +export type AgentNotification = { + readonly method: string; + readonly params?: + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; + readonly update: + | { + readonly sessionUpdate: "user_message_chunk"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + readonly messageId?: string | null; + } + | { + readonly sessionUpdate: "agent_message_chunk"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + readonly messageId?: string | null; + } + | { + readonly sessionUpdate: "agent_thought_chunk"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + readonly messageId?: string | null; + } + | { + readonly sessionUpdate: "tool_call"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content?: ReadonlyArray; + readonly kind?: + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + readonly locations?: ReadonlyArray; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly status?: "pending" | "in_progress" | "completed" | "failed"; + readonly title: string; + readonly toolCallId: string; + } + | { + readonly sessionUpdate: "tool_call_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content?: ReadonlyArray | null; + readonly kind?: ToolKind | null; + readonly locations?: ReadonlyArray | null; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly status?: ToolCallStatus | null; + readonly title?: string | null; + readonly toolCallId: string; + } + | { + readonly sessionUpdate: "plan"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly entries: ReadonlyArray; + } + | { + readonly sessionUpdate: "available_commands_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly availableCommands: ReadonlyArray; + } + | { + readonly sessionUpdate: "current_mode_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly currentModeId: string; + } + | { + readonly sessionUpdate: "config_option_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions: ReadonlyArray; + } + | { + readonly sessionUpdate: "session_info_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly title?: string | null; + readonly updatedAt?: string | null; + } + | { + readonly sessionUpdate: "usage_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cost?: Cost | null; + readonly size: number; + readonly used: number; + }; + } + | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly elicitationId: string } + | unknown + | null; +}; +export const AgentNotification = Schema.Struct({ + method: Schema.String, + params: Schema.optionalKey( + Schema.Union([ + Schema.Union([ + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + update: Schema.Union( + [ + Schema.Struct({ + sessionUpdate: Schema.Literal("user_message_chunk"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: + "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + }).annotate({ description: "A streamed item of content" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_message_chunk"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: + "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + }).annotate({ description: "A streamed item of content" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_thought_chunk"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: + "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + }).annotate({ description: "A streamed item of content" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.optionalKey( + Schema.Array(ToolCallContent).annotate({ + description: "Content produced by the tool call.", + }), + ), + kind: Schema.optionalKey( + Schema.Literals([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", + ]).annotate({ + description: + "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)", + }), + ), + locations: Schema.optionalKey( + Schema.Array(ToolCallLocation).annotate({ + description: + 'File locations affected by this tool call.\nEnables "follow-along" features in clients.', + }), + ), + rawInput: Schema.optionalKey( + Schema.Unknown.annotate({ + description: "Raw input parameters sent to the tool.", + }), + ), + rawOutput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Raw output returned by the tool." }), + ), + status: Schema.optionalKey( + Schema.Literals(["pending", "in_progress", "completed", "failed"]).annotate({ + description: + "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)", + }), + ), + title: Schema.String.annotate({ + description: "Human-readable title describing what the tool is doing.", + }), + toolCallId: Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", + }), + }).annotate({ + description: + "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallContent).annotate({ + description: "Replace the content collection.", + }), + Schema.Null, + ]), + ), + kind: Schema.optionalKey( + Schema.Union([ToolKind, Schema.Null]).annotate({ + description: "Update the tool kind.", + }), + ), + locations: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallLocation).annotate({ + description: "Replace the locations collection.", + }), + Schema.Null, + ]), + ), + rawInput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Update the raw input." }), + ), + rawOutput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Update the raw output." }), + ), + status: Schema.optionalKey( + Schema.Union([ToolCallStatus, Schema.Null]).annotate({ + description: "Update the execution status.", + }), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Update the human-readable title." }), + Schema.Null, + ]), + ), + toolCallId: Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", + }), + }).annotate({ + description: + "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("plan"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + entries: Schema.Array(PlanEntry).annotate({ + description: + "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", + }), + }).annotate({ + description: + "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("available_commands_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + availableCommands: Schema.Array(AvailableCommand).annotate({ + description: "Commands the agent can execute", + }), + }).annotate({ description: "Available commands are ready or have changed" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("current_mode_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + currentModeId: Schema.String.annotate({ + description: "Unique identifier for a Session Mode.", + }), + }).annotate({ + description: + "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("config_option_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.Array(SessionConfigOption).annotate({ + description: "The full set of configuration options and their current values.", + }), + }).annotate({ description: "Session configuration options have been updated." }), + Schema.Struct({ + sessionUpdate: Schema.Literal("session_info_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Human-readable title for the session. Set to null to clear.", + }), + Schema.Null, + ]), + ), + updatedAt: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "ISO 8601 timestamp of last activity. Set to null to clear.", + }), + Schema.Null, + ]), + ), + }).annotate({ + description: + "Update to session metadata. All fields are optional to support partial updates.\n\nAgents send this notification to update session information like title or custom metadata.\nThis allows clients to display dynamic session names and track session state changes.", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("usage_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cost: Schema.optionalKey( + Schema.Union([Cost, Schema.Null]).annotate({ + description: "Cumulative session cost (optional).", + }), + ), + size: Schema.Number.annotate({ + description: "Total context window size in tokens.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + used: Schema.Number.annotate({ + description: "Tokens currently in context.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nContext window and cost update for a session.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Different types of updates that can be sent during session processing.\n\nThese updates provide real-time feedback about the agent's progress.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + }), + }).annotate({ + title: "SessionNotification", + description: + "Notification containing a session update from the agent.\n\nUsed to stream real-time progress and results during prompt processing.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + elicitationId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.", + }), + }).annotate({ + title: "ElicitationCompleteNotification", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification sent by the agent when a URL-based elicitation is complete.", + }), + Schema.Unknown.annotate({ + title: "ExtNotification", + description: + "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + ]).annotate({ + description: + "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Client`] trait instead.\n\nNotifications do not expect a response.", + }), + Schema.Null, + ]), + ), +}); + +export type AgentRequest = { + readonly id: RequestId; + readonly method: string; + readonly params?: + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: string; + readonly path: string; + readonly sessionId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly limit?: number | null; + readonly line?: number | null; + readonly path: string; + readonly sessionId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly options: ReadonlyArray; + readonly sessionId: string; + readonly toolCall: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content?: ReadonlyArray | null; + readonly kind?: ToolKind | null; + readonly locations?: ReadonlyArray | null; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly status?: ToolCallStatus | null; + readonly title?: string | null; + readonly toolCallId: string; + }; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly args?: ReadonlyArray; + readonly command: string; + readonly cwd?: string | null; + readonly env?: ReadonlyArray; + readonly outputByteLimit?: number | null; + readonly sessionId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; + readonly terminalId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; + readonly terminalId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; + readonly terminalId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; + readonly terminalId: string; + } + | { + readonly mode: "form"; + readonly requestedSchema: { + readonly description?: string | null; + readonly properties?: { readonly [x: string]: ElicitationPropertySchema }; + readonly required?: ReadonlyArray | null; + readonly title?: string | null; + readonly type?: "object"; + }; + } + | { readonly mode: "url"; readonly elicitationId: string; readonly url: string } + | unknown + | null; +}; +export const AgentRequest = Schema.Struct({ + id: RequestId, + method: Schema.String, + params: Schema.optionalKey( + Schema.Union([ + Schema.Union([ + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.String.annotate({ + description: "The text content to write to the file.", + }), + path: Schema.String.annotate({ description: "Absolute path to the file to write." }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "WriteTextFileRequest", + description: + "Request to write content to a text file.\n\nOnly available if the client supports the `fs.writeTextFile` capability.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + limit: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Maximum number of lines to read.", + format: "uint32", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + line: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Line number to start reading from (1-based).", + format: "uint32", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + path: Schema.String.annotate({ description: "Absolute path to the file to read." }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "ReadTextFileRequest", + description: + "Request to read content from a text file.\n\nOnly available if the client supports the `fs.readTextFile` capability.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + options: Schema.Array(PermissionOption).annotate({ + description: "Available permission options for the user to choose from.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + toolCall: Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallContent).annotate({ + description: "Replace the content collection.", + }), + Schema.Null, + ]), + ), + kind: Schema.optionalKey( + Schema.Union([ToolKind, Schema.Null]).annotate({ + description: "Update the tool kind.", + }), + ), + locations: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallLocation).annotate({ + description: "Replace the locations collection.", + }), + Schema.Null, + ]), + ), + rawInput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Update the raw input." }), + ), + rawOutput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Update the raw output." }), + ), + status: Schema.optionalKey( + Schema.Union([ToolCallStatus, Schema.Null]).annotate({ + description: "Update the execution status.", + }), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Update the human-readable title." }), + Schema.Null, + ]), + ), + toolCallId: Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", + }), + }).annotate({ + description: + "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", + }), + }).annotate({ + title: "RequestPermissionRequest", + description: + "Request for user permission to execute a tool call.\n\nSent when the agent needs authorization before performing a sensitive operation.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + args: Schema.optionalKey( + Schema.Array(Schema.String).annotate({ description: "Array of command arguments." }), + ), + command: Schema.String.annotate({ description: "The command to execute." }), + cwd: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Working directory for the command (absolute path).", + }), + Schema.Null, + ]), + ), + env: Schema.optionalKey( + Schema.Array(EnvVariable).annotate({ + description: "Environment variables for the command.", + }), + ), + outputByteLimit: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: + "Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "CreateTerminalRequest", + description: "Request to create a new terminal and execute a command.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + terminalId: Schema.String.annotate({ + description: "The ID of the terminal to get output from.", + }), + }).annotate({ + title: "TerminalOutputRequest", + description: "Request to get the current output and status of a terminal.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + terminalId: Schema.String.annotate({ description: "The ID of the terminal to release." }), + }).annotate({ + title: "ReleaseTerminalRequest", + description: "Request to release a terminal and free its resources.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + terminalId: Schema.String.annotate({ + description: "The ID of the terminal to wait for.", + }), + }).annotate({ + title: "WaitForTerminalExitRequest", + description: "Request to wait for a terminal command to exit.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + terminalId: Schema.String.annotate({ description: "The ID of the terminal to kill." }), + }).annotate({ + title: "KillTerminalRequest", + description: "Request to kill a terminal without releasing it.", + }), + Schema.Union( + [ + Schema.Struct({ + mode: Schema.Literal("form"), + requestedSchema: Schema.Struct({ + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional description of what this schema represents.", + }), + Schema.Null, + ]), + ), + properties: Schema.optionalKey( + Schema.Record(Schema.String, ElicitationPropertySchema).annotate({ + description: "Property definitions (must be primitive types).", + default: {}, + }), + ), + required: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.String).annotate({ + description: "List of required property names.", + }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the schema." }), + Schema.Null, + ]), + ), + type: Schema.optionalKey( + Schema.Literal("object").annotate({ + description: "Type discriminator for elicitation schemas.", + default: "object", + }), + ), + }).annotate({ + description: + "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", + }), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation mode where the client renders a form from the provided schema.", + }), + Schema.Struct({ + mode: Schema.Literal("url"), + elicitationId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.", + }), + url: Schema.String.annotate({ + description: "The URL to direct the user to.", + format: "uri", + }), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation mode where the client directs the user to a URL.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + title: "ElicitationRequest", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.", + }), + Schema.Unknown.annotate({ + title: "ExtMethodRequest", + description: + "Allows for sending an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + ]).annotate({ + description: + "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly - instead, use the methods on the [`Client`] trait.\n\nThis enum encompasses all method calls from agent to client.", + }), + Schema.Null, + ]), + ), +}); + +export type AgentResponse = + | { + readonly id: RequestId; + readonly result: + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly agentCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly auth?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly logout?: LogoutCapabilities | null; + }; + readonly loadSession?: boolean; + readonly mcpCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly http?: boolean; + readonly sse?: boolean; + }; + readonly promptCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly audio?: boolean; + readonly embeddedContext?: boolean; + readonly image?: boolean; + }; + readonly sessionCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly close?: SessionCloseCapabilities | null; + readonly fork?: SessionForkCapabilities | null; + readonly list?: SessionListCapabilities | null; + readonly resume?: SessionResumeCapabilities | null; + }; + }; + readonly agentInfo?: Implementation | null; + readonly authMethods?: ReadonlyArray; + readonly protocolVersion: number; + } + | { readonly _meta?: { readonly [x: string]: unknown } | null } + | { readonly _meta?: { readonly [x: string]: unknown } | null } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions?: ReadonlyArray | null; + readonly models?: SessionModelState | null; + readonly modes?: SessionModeState | null; + readonly sessionId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions?: ReadonlyArray | null; + readonly models?: SessionModelState | null; + readonly modes?: SessionModeState | null; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly nextCursor?: string | null; + readonly sessions: ReadonlyArray; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions?: ReadonlyArray | null; + readonly models?: SessionModelState | null; + readonly modes?: SessionModeState | null; + readonly sessionId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions?: ReadonlyArray | null; + readonly models?: SessionModelState | null; + readonly modes?: SessionModeState | null; + } + | { readonly _meta?: { readonly [x: string]: unknown } | null } + | { readonly _meta?: { readonly [x: string]: unknown } | null } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions: ReadonlyArray; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly stopReason: + | "end_turn" + | "max_tokens" + | "max_turn_requests" + | "refusal" + | "cancelled"; + readonly usage?: Usage | null; + readonly userMessageId?: string | null; + } + | { readonly _meta?: { readonly [x: string]: unknown } | null } + | unknown; + } + | { readonly error: Error; readonly id: RequestId }; +export const AgentResponse = Schema.Union([ + Schema.Struct({ + id: RequestId, + result: Schema.Union([ + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + agentCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + auth: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + logout: Schema.optionalKey( + Schema.Union([LogoutCapabilities, Schema.Null]).annotate({ + description: + "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.", + }), + ), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication-related capabilities supported by the agent.", + default: {}, + }), + ), + loadSession: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the agent supports `session/load`.", + default: false, + }), + ), + mcpCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + http: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`McpServer::Http`].", + default: false, + }), + ), + sse: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`McpServer::Sse`].", + default: false, + }), + ), + }).annotate({ + description: "MCP capabilities supported by the agent", + default: { http: false, sse: false }, + }), + ), + promptCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + audio: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`ContentBlock::Audio`].", + default: false, + }), + ), + embeddedContext: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.", + default: false, + }), + ), + image: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`ContentBlock::Image`].", + default: false, + }), + ), + }).annotate({ + description: + "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)", + default: { audio: false, embeddedContext: false, image: false }, + }), + ), + sessionCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + close: Schema.optionalKey( + Schema.Union([SessionCloseCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/close`.", + }), + ), + fork: Schema.optionalKey( + Schema.Union([SessionForkCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.", + }), + ), + list: Schema.optionalKey( + Schema.Union([SessionListCapabilities, Schema.Null]).annotate({ + description: "Whether the agent supports `session/list`.", + }), + ), + resume: Schema.optionalKey( + Schema.Union([SessionResumeCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/resume`.", + }), + ), + }).annotate({ + default: {}, + description: + "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)", + }), + ), + }).annotate({ + description: + "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)", + default: { + auth: {}, + loadSession: false, + mcpCapabilities: { http: false, sse: false }, + promptCapabilities: { audio: false, embeddedContext: false, image: false }, + sessionCapabilities: {}, + }, + }), + ), + agentInfo: Schema.optionalKey( + Schema.Union([Implementation, Schema.Null]).annotate({ + description: + "Information about the Agent name and version sent to the Client.\n\nNote: in future versions of the protocol, this will be required.", + }), + ), + authMethods: Schema.optionalKey( + Schema.Array(AuthMethod).annotate({ + description: "Authentication methods supported by the agent.", + default: [], + }), + ), + protocolVersion: Schema.Number.annotate({ + description: + "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.", + format: "uint16", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)) + .check(Schema.isLessThanOrEqualTo(65535)), + }).annotate({ + title: "InitializeResponse", + description: + "Response to the `initialize` method.\n\nContains the negotiated protocol version and agent capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "AuthenticateResponse", + description: "Response to the `authenticate` method.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "LogoutResponse", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse to the `logout` method.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.optionalKey( + Schema.Union([ + Schema.Array(SessionConfigOption).annotate({ + description: "Initial session configuration options if supported by the Agent.", + }), + Schema.Null, + ]), + ), + models: Schema.optionalKey( + Schema.Union([SessionModelState, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent", + }), + ), + modes: Schema.optionalKey( + Schema.Union([SessionModeState, Schema.Null]).annotate({ + description: + "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "NewSessionResponse", + description: + "Response from creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.optionalKey( + Schema.Union([ + Schema.Array(SessionConfigOption).annotate({ + description: "Initial session configuration options if supported by the Agent.", + }), + Schema.Null, + ]), + ), + models: Schema.optionalKey( + Schema.Union([SessionModelState, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent", + }), + ), + modes: Schema.optionalKey( + Schema.Union([SessionModeState, Schema.Null]).annotate({ + description: + "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + ), + }).annotate({ + title: "LoadSessionResponse", + description: "Response from loading an existing session.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + nextCursor: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Opaque cursor token. If present, pass this in the next request's cursor parameter\nto fetch the next page. If absent, there are no more results.", + }), + Schema.Null, + ]), + ), + sessions: Schema.Array(SessionInfo).annotate({ + description: "Array of session information objects", + }), + }).annotate({ + title: "ListSessionsResponse", + description: "Response from listing sessions.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.optionalKey( + Schema.Union([ + Schema.Array(SessionConfigOption).annotate({ + description: "Initial session configuration options if supported by the Agent.", + }), + Schema.Null, + ]), + ), + models: Schema.optionalKey( + Schema.Union([SessionModelState, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent", + }), + ), + modes: Schema.optionalKey( + Schema.Union([SessionModeState, Schema.Null]).annotate({ + description: + "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "ForkSessionResponse", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from forking an existing session.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.optionalKey( + Schema.Union([ + Schema.Array(SessionConfigOption).annotate({ + description: "Initial session configuration options if supported by the Agent.", + }), + Schema.Null, + ]), + ), + models: Schema.optionalKey( + Schema.Union([SessionModelState, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent", + }), + ), + modes: Schema.optionalKey( + Schema.Union([SessionModeState, Schema.Null]).annotate({ + description: + "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + ), + }).annotate({ + title: "ResumeSessionResponse", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from resuming an existing session.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "CloseSessionResponse", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from closing a session.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "SetSessionModeResponse", + description: "Response to `session/set_mode` method.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.Array(SessionConfigOption).annotate({ + description: "The full set of configuration options and their current values.", + }), + }).annotate({ + title: "SetSessionConfigOptionResponse", + description: "Response to `session/set_config_option` method.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + stopReason: Schema.Literals([ + "end_turn", + "max_tokens", + "max_turn_requests", + "refusal", + "cancelled", + ]).annotate({ + description: + "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)", + }), + usage: Schema.optionalKey( + Schema.Union([Usage, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nToken usage for this turn (optional).", + }), + ), + userMessageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe acknowledged user message ID.\n\nIf the client provided a `messageId` in the [`PromptRequest`], the agent echoes it here\nto confirm it was recorded. If the client did not provide one, the agent MAY assign one\nand return it here. Absence of this field indicates the agent did not record a message ID.", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "PromptResponse", + description: + "Response from processing a user prompt.\n\nSee protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion)", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "SetSessionModelResponse", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse to `session/set_model` method.", + }), + Schema.Unknown.annotate({ + title: "ExtMethodResponse", + description: + "Allows for sending an arbitrary response to an [`ExtRequest`] that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + ]).annotate({ + description: + "All possible responses that an agent can send to a client.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding `ClientRequest` variants.", + }), + }).annotate({ title: "Result" }), + Schema.Struct({ error: Error, id: RequestId }).annotate({ title: "Error" }), +]); + +export type AudioContent = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; +}; +export const AudioContent = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, +}).annotate({ description: "Audio provided to or from an LLM." }); + +export type AuthCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly terminal?: boolean; +}; +export const AuthCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + terminal: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.", + default: false, + }), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.", +}); + +export type AuthenticateRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly methodId: string; +}; +export const AuthenticateRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + methodId: Schema.String.annotate({ + description: + "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response.", + }), +}).annotate({ + description: + "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.", +}); + +export type AuthenticateResponse = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const AuthenticateResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Response to the `authenticate` method." }); + +export type AuthMethodAgent = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly description?: string | null; + readonly id: string; + readonly name: string; +}; +export const AuthMethodAgent = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional description providing more details about this authentication method.", + }), + Schema.Null, + ]), + ), + id: Schema.String.annotate({ description: "Unique identifier for this authentication method." }), + name: Schema.String.annotate({ + description: "Human-readable name of the authentication method.", + }), +}).annotate({ + description: + "Agent handles authentication itself.\n\nThis is the default authentication method type.", +}); + +export type AuthMethodEnvVar = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly description?: string | null; + readonly id: string; + readonly link?: string | null; + readonly name: string; + readonly vars: ReadonlyArray; +}; +export const AuthMethodEnvVar = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional description providing more details about this authentication method.", + }), + Schema.Null, + ]), + ), + id: Schema.String.annotate({ description: "Unique identifier for this authentication method." }), + link: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional link to a page where the user can obtain their credentials.", + }), + Schema.Null, + ]), + ), + name: Schema.String.annotate({ + description: "Human-readable name of the authentication method.", + }), + vars: Schema.Array(AuthEnvVar).annotate({ + description: "The environment variables the client should set.", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nEnvironment variable authentication method.\n\nThe user provides credentials that the client passes to the agent as environment variables.", +}); + +export type AuthMethodTerminal = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly args?: ReadonlyArray; + readonly description?: string | null; + readonly env?: { readonly [x: string]: string }; + readonly id: string; + readonly name: string; +}; +export const AuthMethodTerminal = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + args: Schema.optionalKey( + Schema.Array(Schema.String).annotate({ + description: "Additional arguments to pass when running the agent binary for terminal auth.", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional description providing more details about this authentication method.", + }), + Schema.Null, + ]), + ), + env: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional environment variables to set when running the agent binary for terminal auth.", + }), + ), + id: Schema.String.annotate({ description: "Unique identifier for this authentication method." }), + name: Schema.String.annotate({ + description: "Human-readable name of the authentication method.", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nTerminal-based authentication method.\n\nThe client runs an interactive terminal for the user to authenticate via a TUI.", +}); + +export type AvailableCommandsUpdate = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly availableCommands: ReadonlyArray; +}; +export const AvailableCommandsUpdate = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + availableCommands: Schema.Array(AvailableCommand).annotate({ + description: "Commands the agent can execute", + }), +}).annotate({ description: "Available commands are ready or have changed" }); + +export type BlobResourceContents = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly blob: string; + readonly mimeType?: string | null; + readonly uri: string; +}; +export const BlobResourceContents = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + blob: Schema.String, + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, +}).annotate({ description: "Binary resource contents." }); + +export type BooleanPropertySchema = { + readonly default?: boolean | null; + readonly description?: string | null; + readonly title?: string | null; +}; +export const BooleanPropertySchema = Schema.Struct({ + default: Schema.optionalKey( + Schema.Union([Schema.Boolean.annotate({ description: "Default value." }), Schema.Null]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable description." }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the property." }), + Schema.Null, + ]), + ), +}).annotate({ description: "Schema for boolean properties in an elicitation form." }); + +export type CancelNotification = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; +}; +export const CancelNotification = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", +}); + +export type CancelRequestNotification = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly requestId: null | number | string; +}; +export const CancelRequestNotification = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + requestId: Schema.Union([ + Schema.Null.annotate({ title: "Null" }), + Schema.Number.annotate({ title: "Number", format: "int64" }).check(Schema.isInt()), + Schema.String.annotate({ title: "Str" }), + ]).annotate({ + description: + "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions.", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification to cancel an ongoing request.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)", +}); + +export type ClientCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly auth?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly terminal?: boolean; + }; + readonly elicitation?: ElicitationCapabilities | null; + readonly fs?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly readTextFile?: boolean; + readonly writeTextFile?: boolean; + }; + readonly terminal?: boolean; +}; +export const ClientCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + auth: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + terminal: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.", + default: false, + }), + ), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.", + default: { terminal: false }, + }), + ), + elicitation: Schema.optionalKey( + Schema.Union([ElicitationCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.", + }), + ), + fs: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + readTextFile: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client supports `fs/read_text_file` requests.", + default: false, + }), + ), + writeTextFile: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client supports `fs/write_text_file` requests.", + default: false, + }), + ), + }).annotate({ + description: + "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)", + default: { readTextFile: false, writeTextFile: false }, + }), + ), + terminal: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client support all `terminal/*` methods.", + default: false, + }), + ), +}).annotate({ + description: + "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)", +}); + +export type ClientNotification = { + readonly method: string; + readonly params?: + | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly sessionId: string } + | unknown + | null; +}; +export const ClientNotification = Schema.Struct({ + method: Schema.String, + params: Schema.optionalKey( + Schema.Union([ + Schema.Union([ + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "CancelNotification", + description: + "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + }), + Schema.Unknown.annotate({ + title: "ExtNotification", + description: + "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + ]).annotate({ + description: + "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Agent`] trait instead.\n\nNotifications do not expect a response.", + }), + Schema.Null, + ]), + ), +}); + +export type ClientRequest = { + readonly id: RequestId; + readonly method: string; + readonly params?: + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly clientCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly auth?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly terminal?: boolean; + }; + readonly elicitation?: ElicitationCapabilities | null; + readonly fs?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly readTextFile?: boolean; + readonly writeTextFile?: boolean; + }; + readonly terminal?: boolean; + }; + readonly clientInfo?: Implementation | null; + readonly protocolVersion: number; + } + | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly methodId: string } + | { readonly _meta?: { readonly [x: string]: unknown } | null } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cwd: string; + readonly mcpServers: ReadonlyArray; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cwd: string; + readonly mcpServers: ReadonlyArray; + readonly sessionId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cursor?: string | null; + readonly cwd?: string | null; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cwd: string; + readonly mcpServers?: ReadonlyArray; + readonly sessionId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cwd: string; + readonly mcpServers?: ReadonlyArray; + readonly sessionId: string; + } + | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly sessionId: string } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly modeId: string; + readonly sessionId: string; + } + | { readonly type: "boolean"; readonly value: boolean } + | { readonly value: string } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly messageId?: string | null; + readonly prompt: ReadonlyArray; + readonly sessionId: string; + } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly modelId: string; + readonly sessionId: string; + } + | unknown + | null; +}; +export const ClientRequest = Schema.Struct({ + id: RequestId, + method: Schema.String, + params: Schema.optionalKey( + Schema.Union([ + Schema.Union([ + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + clientCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + auth: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + terminal: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.", + default: false, + }), + ), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.", + default: { terminal: false }, + }), + ), + elicitation: Schema.optionalKey( + Schema.Union([ElicitationCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.", + }), + ), + fs: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + readTextFile: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client supports `fs/read_text_file` requests.", + default: false, + }), + ), + writeTextFile: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client supports `fs/write_text_file` requests.", + default: false, + }), + ), + }).annotate({ + description: + "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)", + default: { readTextFile: false, writeTextFile: false }, + }), + ), + terminal: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client support all `terminal/*` methods.", + default: false, + }), + ), + }).annotate({ + description: + "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)", + default: { + auth: { terminal: false }, + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }), + ), + clientInfo: Schema.optionalKey( + Schema.Union([Implementation, Schema.Null]).annotate({ + description: + "Information about the Client name and version sent to the Agent.\n\nNote: in future versions of the protocol, this will be required.", + }), + ), + protocolVersion: Schema.Number.annotate({ + description: + "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.", + format: "uint16", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)) + .check(Schema.isLessThanOrEqualTo(65535)), + }).annotate({ + title: "InitializeRequest", + description: + "Request parameters for the initialize method.\n\nSent by the client to establish connection and negotiate capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + methodId: Schema.String.annotate({ + description: + "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response.", + }), + }).annotate({ + title: "AuthenticateRequest", + description: + "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "LogoutRequest", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for the logout method.\n\nTerminates the current authenticated session.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cwd: Schema.String.annotate({ + description: "The working directory for this session. Must be an absolute path.", + }), + mcpServers: Schema.Array(McpServer).annotate({ + description: + "List of MCP (Model Context Protocol) servers the agent should connect to.", + }), + }).annotate({ + title: "NewSessionRequest", + description: + "Request parameters for creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cwd: Schema.String.annotate({ description: "The working directory for this session." }), + mcpServers: Schema.Array(McpServer).annotate({ + description: "List of MCP servers to connect to for this session.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "LoadSessionRequest", + description: + "Request parameters for loading an existing session.\n\nOnly available if the Agent supports the `loadSession` capability.\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cursor: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Opaque cursor token from a previous response's nextCursor field for cursor-based pagination", + }), + Schema.Null, + ]), + ), + cwd: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Filter sessions by working directory. Must be an absolute path.", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "ListSessionsRequest", + description: + "Request parameters for listing existing sessions.\n\nOnly available if the Agent supports the `sessionCapabilities.list` capability.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cwd: Schema.String.annotate({ description: "The working directory for this session." }), + mcpServers: Schema.optionalKey( + Schema.Array(McpServer).annotate({ + description: "List of MCP servers to connect to for this session.", + }), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "ForkSessionRequest", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for forking an existing session.\n\nCreates a new session based on the context of an existing one, allowing\noperations like generating summaries without affecting the original session's history.\n\nOnly available if the Agent supports the `session.fork` capability.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cwd: Schema.String.annotate({ description: "The working directory for this session." }), + mcpServers: Schema.optionalKey( + Schema.Array(McpServer).annotate({ + description: "List of MCP servers to connect to for this session.", + }), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "ResumeSessionRequest", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for resuming an existing session.\n\nResumes an existing session without returning previous messages (unlike `session/load`).\nThis is useful for agents that can resume sessions but don't implement full session loading.\n\nOnly available if the Agent supports the `session.resume` capability.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "CloseSessionRequest", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for closing an active session.\n\nIf supported, the agent **must** cancel any ongoing work related to the session\n(treat it as if `session/cancel` was called) and then free up any resources\nassociated with the session.\n\nOnly available if the Agent supports the `session.close` capability.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + modeId: Schema.String.annotate({ description: "Unique identifier for a Session Mode." }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "SetSessionModeRequest", + description: "Request parameters for setting a session mode.", + }), + Schema.Union([ + Schema.Struct({ + type: Schema.Literal("boolean"), + value: Schema.Boolean.annotate({ description: "The boolean value." }), + }).annotate({ description: 'A boolean value (`type: "boolean"`).' }), + Schema.Struct({ + value: Schema.String.annotate({ + description: "Unique identifier for a session configuration option value.", + }), + }).annotate({ + title: "value_id", + description: + "A [`SessionConfigValueId`] string value.\n\nThis is the default when `type` is absent on the wire. Unknown `type`\nvalues with string payloads also gracefully deserialize into this\nvariant.", + }), + ]).annotate({ + title: "SetSessionConfigOptionRequest", + description: "Request parameters for setting a session configuration option.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA client-generated unique identifier for this user message.\n\nIf provided, the Agent SHOULD echo this value as `userMessageId` in the\n[`PromptResponse`] to confirm it was recorded.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + prompt: Schema.Array(ContentBlock).annotate({ + description: + "The blocks of content that compose the user's message.\n\nAs a baseline, the Agent MUST support [`ContentBlock::Text`] and [`ContentBlock::ResourceLink`],\nwhile other variants are optionally enabled via [`PromptCapabilities`].\n\nThe Client MUST adapt its interface according to [`PromptCapabilities`].\n\nThe client MAY include referenced pieces of context as either\n[`ContentBlock::Resource`] or [`ContentBlock::ResourceLink`].\n\nWhen available, [`ContentBlock::Resource`] is preferred\nas it avoids extra round-trips and allows the message to include\npieces of context from sources the agent may not have access to.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "PromptRequest", + description: + "Request parameters for sending a user prompt to the agent.\n\nContains the user's message and any additional context.\n\nSee protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message)", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + modelId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for a model.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + title: "SetSessionModelRequest", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for setting a session model.", + }), + Schema.Unknown.annotate({ + title: "ExtMethodRequest", + description: + "Allows for sending an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + ]).annotate({ + description: + "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly - instead, use the methods on the [`Agent`] trait.\n\nThis enum encompasses all method calls from client to agent.", + }), + Schema.Null, + ]), + ), +}); + +export type ClientResponse = + | { + readonly id: RequestId; + readonly result: + | { readonly _meta?: { readonly [x: string]: unknown } | null } + | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly content: string } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly outcome: + | { readonly outcome: "cancelled" } + | { + readonly outcome: "selected"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly optionId: string; + }; + } + | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly terminalId: string } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly exitStatus?: TerminalExitStatus | null; + readonly output: string; + readonly truncated: boolean; + } + | { readonly _meta?: { readonly [x: string]: unknown } | null } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly exitCode?: number | null; + readonly signal?: string | null; + } + | { readonly _meta?: { readonly [x: string]: unknown } | null } + | { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly action: + | { + readonly action: "accept"; + readonly content?: { readonly [x: string]: ElicitationContentValue } | null; + } + | { readonly action: "decline" } + | { readonly action: "cancel" }; + } + | unknown; + } + | { readonly error: Error; readonly id: RequestId }; +export const ClientResponse = Schema.Union([ + Schema.Struct({ + id: RequestId, + result: Schema.Union([ + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "WriteTextFileResponse", + description: "Response to `fs/write_text_file`", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.String, + }).annotate({ + title: "ReadTextFileResponse", + description: "Response containing the contents of a text file.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + outcome: Schema.Union( + [ + Schema.Struct({ outcome: Schema.Literal("cancelled") }).annotate({ + description: + "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + }), + Schema.Struct({ + outcome: Schema.Literal("selected"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + optionId: Schema.String.annotate({ + description: "Unique identifier for a permission option.", + }), + }).annotate({ description: "The user selected one of the provided options." }), + ], + { mode: "oneOf" }, + ).annotate({ description: "The outcome of a permission request." }), + }).annotate({ + title: "RequestPermissionResponse", + description: "Response to a permission request.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + terminalId: Schema.String.annotate({ + description: "The unique identifier for the created terminal.", + }), + }).annotate({ + title: "CreateTerminalResponse", + description: "Response containing the ID of the created terminal.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + exitStatus: Schema.optionalKey( + Schema.Union([TerminalExitStatus, Schema.Null]).annotate({ + description: "Exit status if the command has completed.", + }), + ), + output: Schema.String.annotate({ description: "The terminal output captured so far." }), + truncated: Schema.Boolean.annotate({ + description: "Whether the output was truncated due to byte limits.", + }), + }).annotate({ + title: "TerminalOutputResponse", + description: "Response containing the terminal output and exit status.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "ReleaseTerminalResponse", + description: "Response to terminal/release method", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + exitCode: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "The process exit code (may be null if terminated by signal).", + format: "uint32", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + signal: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "The signal that terminated the process (may be null if exited normally).", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "WaitForTerminalExitResponse", + description: "Response containing the exit status of a terminal command.", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + }).annotate({ + title: "KillTerminalResponse", + description: "Response to `terminal/kill` method", + }), + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + action: Schema.Union( + [ + Schema.Struct({ + action: Schema.Literal("accept"), + content: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, ElicitationContentValue).annotate({ + description: + "The user-provided content, if any, as an object matching the requested schema.", + }), + Schema.Null, + ]), + ), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user accepted the elicitation and provided content.", + }), + Schema.Struct({ action: Schema.Literal("decline") }).annotate({ + description: "The user declined the elicitation.", + }), + Schema.Struct({ action: Schema.Literal("cancel") }).annotate({ + description: "The elicitation was cancelled.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user's action in response to an elicitation.", + }), + }).annotate({ + title: "ElicitationResponse", + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from the client to an elicitation request.", + }), + Schema.Unknown.annotate({ + title: "ExtMethodResponse", + description: + "Allows for sending an arbitrary response to an [`ExtRequest`] that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + ]).annotate({ + description: + "All possible responses that a client can send to an agent.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding `AgentRequest` variants.", + }), + }).annotate({ title: "Result" }), + Schema.Struct({ error: Error, id: RequestId }).annotate({ title: "Error" }), +]); + +export type CloseSessionRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; +}; +export const CloseSessionRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for closing an active session.\n\nIf supported, the agent **must** cancel any ongoing work related to the session\n(treat it as if `session/cancel` was called) and then free up any resources\nassociated with the session.\n\nOnly available if the Agent supports the `session.close` capability.", +}); + +export type CloseSessionResponse = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const CloseSessionResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from closing a session.", +}); + +export type ConfigOptionUpdate = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions: ReadonlyArray; +}; +export const ConfigOptionUpdate = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.Array(SessionConfigOption).annotate({ + description: "The full set of configuration options and their current values.", + }), +}).annotate({ description: "Session configuration options have been updated." }); + +export type Content = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; +}; +export const Content = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), +}).annotate({ description: "Standard content block (text, images, resources)." }); + +export type ContentChunk = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + readonly messageId?: string | null; +}; +export const ContentChunk = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "A streamed item of content" }); + +export type CreateTerminalRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly args?: ReadonlyArray; + readonly command: string; + readonly cwd?: string | null; + readonly env?: ReadonlyArray; + readonly outputByteLimit?: number | null; + readonly sessionId: string; +}; +export const CreateTerminalRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + args: Schema.optionalKey( + Schema.Array(Schema.String).annotate({ description: "Array of command arguments." }), + ), + command: Schema.String.annotate({ description: "The command to execute." }), + cwd: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Working directory for the command (absolute path)." }), + Schema.Null, + ]), + ), + env: Schema.optionalKey( + Schema.Array(EnvVariable).annotate({ description: "Environment variables for the command." }), + ), + outputByteLimit: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: + "Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ description: "Request to create a new terminal and execute a command." }); + +export type CreateTerminalResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly terminalId: string; +}; +export const CreateTerminalResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + terminalId: Schema.String.annotate({ + description: "The unique identifier for the created terminal.", + }), +}).annotate({ description: "Response containing the ID of the created terminal." }); + +export type CurrentModeUpdate = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly currentModeId: string; +}; +export const CurrentModeUpdate = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + currentModeId: Schema.String.annotate({ description: "Unique identifier for a Session Mode." }), +}).annotate({ + description: + "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", +}); + +export type Diff = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly newText: string; + readonly oldText?: string | null; + readonly path: string; +}; +export const Diff = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + newText: Schema.String.annotate({ description: "The new content after modification." }), + oldText: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "The original content (None for new files)." }), + Schema.Null, + ]), + ), + path: Schema.String.annotate({ description: "The file path being modified." }), +}).annotate({ + description: + "A diff representing file modifications.\n\nShows changes to files in a format suitable for display in the client UI.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", +}); + +export type ElicitationAcceptAction = { + readonly content?: { readonly [x: string]: ElicitationContentValue } | null; +}; +export const ElicitationAcceptAction = Schema.Struct({ + content: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, ElicitationContentValue).annotate({ + description: + "The user-provided content, if any, as an object matching the requested schema.", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user accepted the elicitation and provided content.", +}); + +export type ElicitationAction = + | { + readonly action: "accept"; + readonly content?: { readonly [x: string]: ElicitationContentValue } | null; + } + | { readonly action: "decline" } + | { readonly action: "cancel" }; +export const ElicitationAction = Schema.Union( + [ + Schema.Struct({ + action: Schema.Literal("accept"), + content: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, ElicitationContentValue).annotate({ + description: + "The user-provided content, if any, as an object matching the requested schema.", + }), + Schema.Null, + ]), + ), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user accepted the elicitation and provided content.", + }), + Schema.Struct({ action: Schema.Literal("decline") }).annotate({ + description: "The user declined the elicitation.", + }), + Schema.Struct({ action: Schema.Literal("cancel") }).annotate({ + description: "The elicitation was cancelled.", + }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user's action in response to an elicitation.", +}); + +export type ElicitationCompleteNotification = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly elicitationId: string; +}; +export const ElicitationCompleteNotification = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + elicitationId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification sent by the agent when a URL-based elicitation is complete.", +}); + +export type ElicitationFormMode = { + readonly requestedSchema: { + readonly description?: string | null; + readonly properties?: { readonly [x: string]: ElicitationPropertySchema }; + readonly required?: ReadonlyArray | null; + readonly title?: string | null; + readonly type?: "object"; + }; +}; +export const ElicitationFormMode = Schema.Struct({ + requestedSchema: Schema.Struct({ + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional description of what this schema represents.", + }), + Schema.Null, + ]), + ), + properties: Schema.optionalKey( + Schema.Record(Schema.String, ElicitationPropertySchema).annotate({ + description: "Property definitions (must be primitive types).", + default: {}, + }), + ), + required: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.String).annotate({ description: "List of required property names." }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the schema." }), + Schema.Null, + ]), + ), + type: Schema.optionalKey( + Schema.Literal("object").annotate({ + description: "Type discriminator for elicitation schemas.", + default: "object", + }), + ), + }).annotate({ + description: + "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation mode where the client renders a form from the provided schema.", +}); + +export type ElicitationId = string; +export const ElicitationId = Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.", +}); + +export type ElicitationRequest = + | { + readonly mode: "form"; + readonly requestedSchema: { + readonly description?: string | null; + readonly properties?: { readonly [x: string]: ElicitationPropertySchema }; + readonly required?: ReadonlyArray | null; + readonly title?: string | null; + readonly type?: "object"; + }; + } + | { readonly mode: "url"; readonly elicitationId: string; readonly url: string }; +export const ElicitationRequest = Schema.Union( + [ + Schema.Struct({ + mode: Schema.Literal("form"), + requestedSchema: Schema.Struct({ + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional description of what this schema represents.", + }), + Schema.Null, + ]), + ), + properties: Schema.optionalKey( + Schema.Record(Schema.String, ElicitationPropertySchema).annotate({ + description: "Property definitions (must be primitive types).", + default: {}, + }), + ), + required: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.String).annotate({ + description: "List of required property names.", + }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the schema." }), + Schema.Null, + ]), + ), + type: Schema.optionalKey( + Schema.Literal("object").annotate({ + description: "Type discriminator for elicitation schemas.", + default: "object", + }), + ), + }).annotate({ + description: + "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", + }), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation mode where the client renders a form from the provided schema.", + }), + Schema.Struct({ + mode: Schema.Literal("url"), + elicitationId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.", + }), + url: Schema.String.annotate({ description: "The URL to direct the user to.", format: "uri" }), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation mode where the client directs the user to a URL.", + }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.", +}); + +export type ElicitationResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly action: + | { + readonly action: "accept"; + readonly content?: { readonly [x: string]: ElicitationContentValue } | null; + } + | { readonly action: "decline" } + | { readonly action: "cancel" }; +}; +export const ElicitationResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + action: Schema.Union( + [ + Schema.Struct({ + action: Schema.Literal("accept"), + content: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, ElicitationContentValue).annotate({ + description: + "The user-provided content, if any, as an object matching the requested schema.", + }), + Schema.Null, + ]), + ), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user accepted the elicitation and provided content.", + }), + Schema.Struct({ action: Schema.Literal("decline") }).annotate({ + description: "The user declined the elicitation.", + }), + Schema.Struct({ action: Schema.Literal("cancel") }).annotate({ + description: "The elicitation was cancelled.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user's action in response to an elicitation.", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from the client to an elicitation request.", +}); + +export type ElicitationSchema = { + readonly description?: string | null; + readonly properties?: { readonly [x: string]: ElicitationPropertySchema }; + readonly required?: ReadonlyArray | null; + readonly title?: string | null; + readonly type?: "object"; +}; +export const ElicitationSchema = Schema.Struct({ + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional description of what this schema represents.", + }), + Schema.Null, + ]), + ), + properties: Schema.optionalKey( + Schema.Record(Schema.String, ElicitationPropertySchema).annotate({ + description: "Property definitions (must be primitive types).", + default: {}, + }), + ), + required: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.String).annotate({ description: "List of required property names." }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the schema." }), + Schema.Null, + ]), + ), + type: Schema.optionalKey( + Schema.Literal("object").annotate({ + description: "Type discriminator for elicitation schemas.", + default: "object", + }), + ), +}).annotate({ + description: + "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", +}); + +export type ElicitationSchemaType = "object"; +export const ElicitationSchemaType = Schema.Literal("object").annotate({ + description: "Type discriminator for elicitation schemas.", +}); + +export type ElicitationStringType = "string"; +export const ElicitationStringType = Schema.Literal("string").annotate({ + description: "Items definition for untitled multi-select enum properties.", +}); + +export type ElicitationUrlMode = { readonly elicitationId: string; readonly url: string }; +export const ElicitationUrlMode = Schema.Struct({ + elicitationId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.", + }), + url: Schema.String.annotate({ description: "The URL to direct the user to.", format: "uri" }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation mode where the client directs the user to a URL.", +}); + +export type EmbeddedResource = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; +}; +export const EmbeddedResource = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, +}).annotate({ + description: "The contents of a resource, embedded into a prompt or tool call result.", +}); + +export type ErrorCode = + | -32700 + | -32600 + | -32601 + | -32602 + | -32603 + | -32800 + | -32000 + | -32002 + | -32042 + | number; +export const ErrorCode = Schema.Union([ + Schema.Literal(-32700).annotate({ + title: "Parse error", + description: + "**Parse error**: Invalid JSON was received by the server.\nAn error occurred on the server while parsing the JSON text.", + format: "int32", + }), + Schema.Literal(-32600).annotate({ + title: "Invalid request", + description: "**Invalid request**: The JSON sent is not a valid Request object.", + format: "int32", + }), + Schema.Literal(-32601).annotate({ + title: "Method not found", + description: "**Method not found**: The method does not exist or is not available.", + format: "int32", + }), + Schema.Literal(-32602).annotate({ + title: "Invalid params", + description: "**Invalid params**: Invalid method parameter(s).", + format: "int32", + }), + Schema.Literal(-32603).annotate({ + title: "Internal error", + description: + "**Internal error**: Internal JSON-RPC error.\nReserved for implementation-defined server errors.", + format: "int32", + }), + Schema.Literal(-32800).annotate({ + title: "Request cancelled", + description: + "**Request cancelled**: **UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nExecution of the method was aborted either due to a cancellation request from the caller or\nbecause of resource constraints or shutdown.", + format: "int32", + }), + Schema.Literal(-32000).annotate({ + title: "Authentication required", + description: + "**Authentication required**: Authentication is required before this operation can be performed.", + format: "int32", + }), + Schema.Literal(-32002).annotate({ + title: "Resource not found", + description: "**Resource not found**: A given resource, such as a file, was not found.", + format: "int32", + }), + Schema.Literal(-32042).annotate({ + title: "URL elicitation required", + description: + "**URL elicitation required**: **UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe agent requires user input via a URL-based elicitation before it can proceed.", + format: "int32", + }), + Schema.Number.annotate({ + title: "Other", + description: "Other undefined error code.", + format: "int32", + }).check(Schema.isInt()), +]).annotate({ + description: + "Predefined error codes for common JSON-RPC and ACP-specific errors.\n\nThese codes follow the JSON-RPC 2.0 specification for standard errors\nand use the reserved range (-32000 to -32099) for protocol-specific errors.", +}); + +export type ExtNotification = unknown; +export const ExtNotification = Schema.Unknown.annotate({ + description: + "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", +}); + +export type ExtRequest = unknown; +export const ExtRequest = Schema.Unknown.annotate({ + description: + "Allows for sending an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", +}); + +export type ExtResponse = unknown; +export const ExtResponse = Schema.Unknown.annotate({ + description: + "Allows for sending an arbitrary response to an [`ExtRequest`] that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", +}); + +export type FileSystemCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly readTextFile?: boolean; + readonly writeTextFile?: boolean; +}; +export const FileSystemCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + readTextFile: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client supports `fs/read_text_file` requests.", + default: false, + }), + ), + writeTextFile: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client supports `fs/write_text_file` requests.", + default: false, + }), + ), +}).annotate({ + description: + "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)", +}); + +export type ForkSessionRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cwd: string; + readonly mcpServers?: ReadonlyArray; + readonly sessionId: string; +}; +export const ForkSessionRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cwd: Schema.String.annotate({ description: "The working directory for this session." }), + mcpServers: Schema.optionalKey( + Schema.Array(McpServer).annotate({ + description: "List of MCP servers to connect to for this session.", + }), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for forking an existing session.\n\nCreates a new session based on the context of an existing one, allowing\noperations like generating summaries without affecting the original session's history.\n\nOnly available if the Agent supports the `session.fork` capability.", +}); + +export type ForkSessionResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions?: ReadonlyArray | null; + readonly models?: SessionModelState | null; + readonly modes?: SessionModeState | null; + readonly sessionId: string; +}; +export const ForkSessionResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.optionalKey( + Schema.Union([ + Schema.Array(SessionConfigOption).annotate({ + description: "Initial session configuration options if supported by the Agent.", + }), + Schema.Null, + ]), + ), + models: Schema.optionalKey( + Schema.Union([SessionModelState, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent", + }), + ), + modes: Schema.optionalKey( + Schema.Union([SessionModeState, Schema.Null]).annotate({ + description: + "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from forking an existing session.", +}); + +export type ImageContent = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; +}; +export const ImageContent = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), +}).annotate({ description: "An image provided to or from an LLM." }); + +export type InitializeRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly clientCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly auth?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly terminal?: boolean; + }; + readonly elicitation?: ElicitationCapabilities | null; + readonly fs?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly readTextFile?: boolean; + readonly writeTextFile?: boolean; + }; + readonly terminal?: boolean; + }; + readonly clientInfo?: Implementation | null; + readonly protocolVersion: number; +}; +export const InitializeRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + clientCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + auth: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + terminal: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.", + default: false, + }), + ), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.", + default: { terminal: false }, + }), + ), + elicitation: Schema.optionalKey( + Schema.Union([ElicitationCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.", + }), + ), + fs: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + readTextFile: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client supports `fs/read_text_file` requests.", + default: false, + }), + ), + writeTextFile: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client supports `fs/write_text_file` requests.", + default: false, + }), + ), + }).annotate({ + description: + "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)", + default: { readTextFile: false, writeTextFile: false }, + }), + ), + terminal: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the Client support all `terminal/*` methods.", + default: false, + }), + ), + }).annotate({ + description: + "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)", + default: { + auth: { terminal: false }, + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }), + ), + clientInfo: Schema.optionalKey( + Schema.Union([Implementation, Schema.Null]).annotate({ + description: + "Information about the Client name and version sent to the Agent.\n\nNote: in future versions of the protocol, this will be required.", + }), + ), + protocolVersion: Schema.Number.annotate({ + description: + "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.", + format: "uint16", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)) + .check(Schema.isLessThanOrEqualTo(65535)), +}).annotate({ + description: + "Request parameters for the initialize method.\n\nSent by the client to establish connection and negotiate capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", +}); + +export type InitializeResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly agentCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly auth?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly logout?: LogoutCapabilities | null; + }; + readonly loadSession?: boolean; + readonly mcpCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly http?: boolean; + readonly sse?: boolean; + }; + readonly promptCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly audio?: boolean; + readonly embeddedContext?: boolean; + readonly image?: boolean; + }; + readonly sessionCapabilities?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly close?: SessionCloseCapabilities | null; + readonly fork?: SessionForkCapabilities | null; + readonly list?: SessionListCapabilities | null; + readonly resume?: SessionResumeCapabilities | null; + }; + }; + readonly agentInfo?: Implementation | null; + readonly authMethods?: ReadonlyArray; + readonly protocolVersion: number; +}; +export const InitializeResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + agentCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + auth: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + logout: Schema.optionalKey( + Schema.Union([LogoutCapabilities, Schema.Null]).annotate({ + description: + "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.", + }), + ), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication-related capabilities supported by the agent.", + default: {}, + }), + ), + loadSession: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Whether the agent supports `session/load`.", + default: false, + }), + ), + mcpCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + http: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`McpServer::Http`].", + default: false, + }), + ), + sse: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`McpServer::Sse`].", + default: false, + }), + ), + }).annotate({ + description: "MCP capabilities supported by the agent", + default: { http: false, sse: false }, + }), + ), + promptCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + audio: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`ContentBlock::Audio`].", + default: false, + }), + ), + embeddedContext: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.", + default: false, + }), + ), + image: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`ContentBlock::Image`].", + default: false, + }), + ), + }).annotate({ + description: + "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)", + default: { audio: false, embeddedContext: false, image: false }, + }), + ), + sessionCapabilities: Schema.optionalKey( + Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + close: Schema.optionalKey( + Schema.Union([SessionCloseCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/close`.", + }), + ), + fork: Schema.optionalKey( + Schema.Union([SessionForkCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.", + }), + ), + list: Schema.optionalKey( + Schema.Union([SessionListCapabilities, Schema.Null]).annotate({ + description: "Whether the agent supports `session/list`.", + }), + ), + resume: Schema.optionalKey( + Schema.Union([SessionResumeCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/resume`.", + }), + ), + }).annotate({ + default: {}, + description: + "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)", + }), + ), + }).annotate({ + description: + "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)", + default: { + auth: {}, + loadSession: false, + mcpCapabilities: { http: false, sse: false }, + promptCapabilities: { audio: false, embeddedContext: false, image: false }, + sessionCapabilities: {}, + }, + }), + ), + agentInfo: Schema.optionalKey( + Schema.Union([Implementation, Schema.Null]).annotate({ + description: + "Information about the Agent name and version sent to the Client.\n\nNote: in future versions of the protocol, this will be required.", + }), + ), + authMethods: Schema.optionalKey( + Schema.Array(AuthMethod).annotate({ + description: "Authentication methods supported by the agent.", + default: [], + }), + ), + protocolVersion: Schema.Number.annotate({ + description: + "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.", + format: "uint16", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)) + .check(Schema.isLessThanOrEqualTo(65535)), +}).annotate({ + description: + "Response to the `initialize` method.\n\nContains the negotiated protocol version and agent capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", +}); + +export type IntegerPropertySchema = { + readonly default?: number | null; + readonly description?: string | null; + readonly maximum?: number | null; + readonly minimum?: number | null; + readonly title?: string | null; +}; +export const IntegerPropertySchema = Schema.Struct({ + default: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Default value.", format: "int64" }).check( + Schema.isInt(), + ), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable description." }), + Schema.Null, + ]), + ), + maximum: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Maximum value (inclusive).", format: "int64" }).check( + Schema.isInt(), + ), + Schema.Null, + ]), + ), + minimum: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Minimum value (inclusive).", format: "int64" }).check( + Schema.isInt(), + ), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the property." }), + Schema.Null, + ]), + ), +}).annotate({ description: "Schema for integer properties in an elicitation form." }); + +export type KillTerminalRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; + readonly terminalId: string; +}; +export const KillTerminalRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + terminalId: Schema.String.annotate({ description: "The ID of the terminal to kill." }), +}).annotate({ description: "Request to kill a terminal without releasing it." }); + +export type KillTerminalResponse = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const KillTerminalResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Response to `terminal/kill` method" }); + +export type ListSessionsRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cursor?: string | null; + readonly cwd?: string | null; +}; +export const ListSessionsRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cursor: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Opaque cursor token from a previous response's nextCursor field for cursor-based pagination", + }), + Schema.Null, + ]), + ), + cwd: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Filter sessions by working directory. Must be an absolute path.", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "Request parameters for listing existing sessions.\n\nOnly available if the Agent supports the `sessionCapabilities.list` capability.", +}); + +export type ListSessionsResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly nextCursor?: string | null; + readonly sessions: ReadonlyArray; +}; +export const ListSessionsResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + nextCursor: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Opaque cursor token. If present, pass this in the next request's cursor parameter\nto fetch the next page. If absent, there are no more results.", + }), + Schema.Null, + ]), + ), + sessions: Schema.Array(SessionInfo).annotate({ + description: "Array of session information objects", + }), +}).annotate({ description: "Response from listing sessions." }); + +export type LoadSessionRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cwd: string; + readonly mcpServers: ReadonlyArray; + readonly sessionId: string; +}; +export const LoadSessionRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cwd: Schema.String.annotate({ description: "The working directory for this session." }), + mcpServers: Schema.Array(McpServer).annotate({ + description: "List of MCP servers to connect to for this session.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "Request parameters for loading an existing session.\n\nOnly available if the Agent supports the `loadSession` capability.\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", +}); + +export type LoadSessionResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions?: ReadonlyArray | null; + readonly models?: SessionModelState | null; + readonly modes?: SessionModeState | null; +}; +export const LoadSessionResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.optionalKey( + Schema.Union([ + Schema.Array(SessionConfigOption).annotate({ + description: "Initial session configuration options if supported by the Agent.", + }), + Schema.Null, + ]), + ), + models: Schema.optionalKey( + Schema.Union([SessionModelState, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent", + }), + ), + modes: Schema.optionalKey( + Schema.Union([SessionModeState, Schema.Null]).annotate({ + description: + "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + ), +}).annotate({ description: "Response from loading an existing session." }); + +export type LogoutRequest = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const LogoutRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for the logout method.\n\nTerminates the current authenticated session.", +}); + +export type LogoutResponse = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const LogoutResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse to the `logout` method.", +}); + +export type McpCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly http?: boolean; + readonly sse?: boolean; +}; +export const McpCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + http: Schema.optionalKey( + Schema.Boolean.annotate({ description: "Agent supports [`McpServer::Http`].", default: false }), + ), + sse: Schema.optionalKey( + Schema.Boolean.annotate({ description: "Agent supports [`McpServer::Sse`].", default: false }), + ), +}).annotate({ description: "MCP capabilities supported by the agent" }); + +export type McpServerHttp = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly headers: ReadonlyArray; + readonly name: string; + readonly url: string; +}; +export const McpServerHttp = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + headers: Schema.Array(HttpHeader).annotate({ + description: "HTTP headers to set when making requests to the MCP server.", + }), + name: Schema.String.annotate({ description: "Human-readable name identifying this MCP server." }), + url: Schema.String.annotate({ description: "URL to the MCP server." }), +}).annotate({ description: "HTTP transport configuration for MCP." }); + +export type McpServerSse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly headers: ReadonlyArray; + readonly name: string; + readonly url: string; +}; +export const McpServerSse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + headers: Schema.Array(HttpHeader).annotate({ + description: "HTTP headers to set when making requests to the MCP server.", + }), + name: Schema.String.annotate({ description: "Human-readable name identifying this MCP server." }), + url: Schema.String.annotate({ description: "URL to the MCP server." }), +}).annotate({ description: "SSE transport configuration for MCP." }); + +export type McpServerStdio = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly args: ReadonlyArray; + readonly command: string; + readonly env: ReadonlyArray; + readonly name: string; +}; +export const McpServerStdio = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + args: Schema.Array(Schema.String).annotate({ + description: "Command-line arguments to pass to the MCP server.", + }), + command: Schema.String.annotate({ description: "Path to the MCP server executable." }), + env: Schema.Array(EnvVariable).annotate({ + description: "Environment variables to set when launching the MCP server.", + }), + name: Schema.String.annotate({ description: "Human-readable name identifying this MCP server." }), +}).annotate({ description: "Stdio transport configuration for MCP." }); + +export type ModelId = string; +export const ModelId = Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for a model.", +}); + +export type MultiSelectItems = + | { readonly enum: ReadonlyArray; readonly type: "string" } + | { readonly anyOf: ReadonlyArray }; +export const MultiSelectItems = Schema.Union([ + Schema.Struct({ + enum: Schema.Array(Schema.String).annotate({ description: "Allowed enum values." }), + type: Schema.Literal("string").annotate({ + description: "Items definition for untitled multi-select enum properties.", + }), + }).annotate({ + title: "Untitled", + description: "Items definition for untitled multi-select enum properties.", + }), + Schema.Struct({ + anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }), + }).annotate({ + title: "Titled", + description: "Items definition for titled multi-select enum properties.", + }), +]).annotate({ description: "Items for a multi-select (array) property schema." }); + +export type MultiSelectPropertySchema = { + readonly default?: ReadonlyArray | null; + readonly description?: string | null; + readonly items: + | { readonly enum: ReadonlyArray; readonly type: "string" } + | { readonly anyOf: ReadonlyArray }; + readonly maxItems?: number | null; + readonly minItems?: number | null; + readonly title?: string | null; +}; +export const MultiSelectPropertySchema = Schema.Struct({ + default: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.String).annotate({ description: "Default selected values." }), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable description." }), + Schema.Null, + ]), + ), + items: Schema.Union([ + Schema.Struct({ + enum: Schema.Array(Schema.String).annotate({ description: "Allowed enum values." }), + type: Schema.Literal("string").annotate({ + description: "Items definition for untitled multi-select enum properties.", + }), + }).annotate({ + title: "Untitled", + description: "Items definition for untitled multi-select enum properties.", + }), + Schema.Struct({ + anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }), + }).annotate({ + title: "Titled", + description: "Items definition for titled multi-select enum properties.", + }), + ]).annotate({ description: "Items for a multi-select (array) property schema." }), + maxItems: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Maximum number of items to select.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + minItems: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Minimum number of items to select.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the property." }), + Schema.Null, + ]), + ), +}).annotate({ description: "Schema for multi-select (array) properties in an elicitation form." }); + +export type NewSessionRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cwd: string; + readonly mcpServers: ReadonlyArray; +}; +export const NewSessionRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cwd: Schema.String.annotate({ + description: "The working directory for this session. Must be an absolute path.", + }), + mcpServers: Schema.Array(McpServer).annotate({ + description: "List of MCP (Model Context Protocol) servers the agent should connect to.", + }), +}).annotate({ + description: + "Request parameters for creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", +}); + +export type NewSessionResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions?: ReadonlyArray | null; + readonly models?: SessionModelState | null; + readonly modes?: SessionModeState | null; + readonly sessionId: string; +}; +export const NewSessionResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.optionalKey( + Schema.Union([ + Schema.Array(SessionConfigOption).annotate({ + description: "Initial session configuration options if supported by the Agent.", + }), + Schema.Null, + ]), + ), + models: Schema.optionalKey( + Schema.Union([SessionModelState, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent", + }), + ), + modes: Schema.optionalKey( + Schema.Union([SessionModeState, Schema.Null]).annotate({ + description: + "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "Response from creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", +}); + +export type NumberPropertySchema = { + readonly default?: number | null; + readonly description?: string | null; + readonly maximum?: number | null; + readonly minimum?: number | null; + readonly title?: string | null; +}; +export const NumberPropertySchema = Schema.Struct({ + default: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Default value.", format: "double" }).check( + Schema.isFinite(), + ), + Schema.Null, + ]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable description." }), + Schema.Null, + ]), + ), + maximum: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Maximum value (inclusive).", format: "double" }).check( + Schema.isFinite(), + ), + Schema.Null, + ]), + ), + minimum: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Minimum value (inclusive).", format: "double" }).check( + Schema.isFinite(), + ), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the property." }), + Schema.Null, + ]), + ), +}).annotate({ + description: "Schema for number (floating-point) properties in an elicitation form.", +}); + +export type PermissionOptionId = string; +export const PermissionOptionId = Schema.String.annotate({ + description: "Unique identifier for a permission option.", +}); + +export type PermissionOptionKind = "allow_once" | "allow_always" | "reject_once" | "reject_always"; +export const PermissionOptionKind = Schema.Literals([ + "allow_once", + "allow_always", + "reject_once", + "reject_always", +]).annotate({ + description: + "The type of permission option being presented to the user.\n\nHelps clients choose appropriate icons and UI treatment.", +}); + +export type Plan = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly entries: ReadonlyArray; +}; +export const Plan = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + entries: Schema.Array(PlanEntry).annotate({ + description: + "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", + }), +}).annotate({ + description: + "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", +}); + +export type PlanEntryPriority = "high" | "medium" | "low"; +export const PlanEntryPriority = Schema.Literals(["high", "medium", "low"]).annotate({ + description: + "Priority levels for plan entries.\n\nUsed to indicate the relative importance or urgency of different\ntasks in the execution plan.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", +}); + +export type PlanEntryStatus = "pending" | "in_progress" | "completed"; +export const PlanEntryStatus = Schema.Literals(["pending", "in_progress", "completed"]).annotate({ + description: + "Status of a plan entry in the execution flow.\n\nTracks the lifecycle of each task from planning through completion.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", +}); + +export type PromptCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly audio?: boolean; + readonly embeddedContext?: boolean; + readonly image?: boolean; +}; +export const PromptCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + audio: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`ContentBlock::Audio`].", + default: false, + }), + ), + embeddedContext: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.", + default: false, + }), + ), + image: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Agent supports [`ContentBlock::Image`].", + default: false, + }), + ), +}).annotate({ + description: + "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)", +}); + +export type PromptRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly messageId?: string | null; + readonly prompt: ReadonlyArray; + readonly sessionId: string; +}; +export const PromptRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA client-generated unique identifier for this user message.\n\nIf provided, the Agent SHOULD echo this value as `userMessageId` in the\n[`PromptResponse`] to confirm it was recorded.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + prompt: Schema.Array(ContentBlock).annotate({ + description: + "The blocks of content that compose the user's message.\n\nAs a baseline, the Agent MUST support [`ContentBlock::Text`] and [`ContentBlock::ResourceLink`],\nwhile other variants are optionally enabled via [`PromptCapabilities`].\n\nThe Client MUST adapt its interface according to [`PromptCapabilities`].\n\nThe client MAY include referenced pieces of context as either\n[`ContentBlock::Resource`] or [`ContentBlock::ResourceLink`].\n\nWhen available, [`ContentBlock::Resource`] is preferred\nas it avoids extra round-trips and allows the message to include\npieces of context from sources the agent may not have access to.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "Request parameters for sending a user prompt to the agent.\n\nContains the user's message and any additional context.\n\nSee protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message)", +}); + +export type PromptResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly stopReason: "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled"; + readonly usage?: Usage | null; + readonly userMessageId?: string | null; +}; +export const PromptResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + stopReason: Schema.Literals([ + "end_turn", + "max_tokens", + "max_turn_requests", + "refusal", + "cancelled", + ]).annotate({ + description: + "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)", + }), + usage: Schema.optionalKey( + Schema.Union([Usage, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nToken usage for this turn (optional).", + }), + ), + userMessageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe acknowledged user message ID.\n\nIf the client provided a `messageId` in the [`PromptRequest`], the agent echoes it here\nto confirm it was recorded. If the client did not provide one, the agent MAY assign one\nand return it here. Absence of this field indicates the agent did not record a message ID.", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "Response from processing a user prompt.\n\nSee protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion)", +}); + +export type ProtocolVersion = number; +export const ProtocolVersion = Schema.Number.annotate({ + description: + "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.", + format: "uint16", +}) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)) + .check(Schema.isLessThanOrEqualTo(65535)); + +export type ReadTextFileRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly limit?: number | null; + readonly line?: number | null; + readonly path: string; + readonly sessionId: string; +}; +export const ReadTextFileRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + limit: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Maximum number of lines to read.", format: "uint32" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + line: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "Line number to start reading from (1-based).", + format: "uint32", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + path: Schema.String.annotate({ description: "Absolute path to the file to read." }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "Request to read content from a text file.\n\nOnly available if the client supports the `fs.readTextFile` capability.", +}); + +export type ReadTextFileResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: string; +}; +export const ReadTextFileResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.String, +}).annotate({ description: "Response containing the contents of a text file." }); + +export type ReleaseTerminalRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; + readonly terminalId: string; +}; +export const ReleaseTerminalRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + terminalId: Schema.String.annotate({ description: "The ID of the terminal to release." }), +}).annotate({ description: "Request to release a terminal and free its resources." }); + +export type ReleaseTerminalResponse = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const ReleaseTerminalResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Response to terminal/release method" }); + +export type RequestPermissionOutcome = + | { readonly outcome: "cancelled" } + | { + readonly outcome: "selected"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly optionId: string; + }; +export const RequestPermissionOutcome = Schema.Union( + [ + Schema.Struct({ outcome: Schema.Literal("cancelled") }).annotate({ + description: + "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + }), + Schema.Struct({ + outcome: Schema.Literal("selected"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + optionId: Schema.String.annotate({ + description: "Unique identifier for a permission option.", + }), + }).annotate({ description: "The user selected one of the provided options." }), + ], + { mode: "oneOf" }, +).annotate({ description: "The outcome of a permission request." }); + +export type RequestPermissionRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly options: ReadonlyArray; + readonly sessionId: string; + readonly toolCall: { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content?: ReadonlyArray | null; + readonly kind?: ToolKind | null; + readonly locations?: ReadonlyArray | null; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly status?: ToolCallStatus | null; + readonly title?: string | null; + readonly toolCallId: string; + }; +}; +export const RequestPermissionRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + options: Schema.Array(PermissionOption).annotate({ + description: "Available permission options for the user to choose from.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + toolCall: Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallContent).annotate({ description: "Replace the content collection." }), + Schema.Null, + ]), + ), + kind: Schema.optionalKey( + Schema.Union([ToolKind, Schema.Null]).annotate({ description: "Update the tool kind." }), + ), + locations: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallLocation).annotate({ + description: "Replace the locations collection.", + }), + Schema.Null, + ]), + ), + rawInput: Schema.optionalKey(Schema.Unknown.annotate({ description: "Update the raw input." })), + rawOutput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Update the raw output." }), + ), + status: Schema.optionalKey( + Schema.Union([ToolCallStatus, Schema.Null]).annotate({ + description: "Update the execution status.", + }), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Update the human-readable title." }), + Schema.Null, + ]), + ), + toolCallId: Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", + }), + }).annotate({ + description: + "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", + }), +}).annotate({ + description: + "Request for user permission to execute a tool call.\n\nSent when the agent needs authorization before performing a sensitive operation.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", +}); + +export type RequestPermissionResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly outcome: + | { readonly outcome: "cancelled" } + | { + readonly outcome: "selected"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly optionId: string; + }; +}; +export const RequestPermissionResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + outcome: Schema.Union( + [ + Schema.Struct({ outcome: Schema.Literal("cancelled") }).annotate({ + description: + "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + }), + Schema.Struct({ + outcome: Schema.Literal("selected"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + optionId: Schema.String.annotate({ + description: "Unique identifier for a permission option.", + }), + }).annotate({ description: "The user selected one of the provided options." }), + ], + { mode: "oneOf" }, + ).annotate({ description: "The outcome of a permission request." }), +}).annotate({ description: "Response to a permission request." }); + +export type ResourceLink = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; +}; +export const ResourceLink = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), Schema.Null]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, +}).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", +}); + +export type ResumeSessionRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cwd: string; + readonly mcpServers?: ReadonlyArray; + readonly sessionId: string; +}; +export const ResumeSessionRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cwd: Schema.String.annotate({ description: "The working directory for this session." }), + mcpServers: Schema.optionalKey( + Schema.Array(McpServer).annotate({ + description: "List of MCP servers to connect to for this session.", + }), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for resuming an existing session.\n\nResumes an existing session without returning previous messages (unlike `session/load`).\nThis is useful for agents that can resume sessions but don't implement full session loading.\n\nOnly available if the Agent supports the `session.resume` capability.", +}); + +export type ResumeSessionResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions?: ReadonlyArray | null; + readonly models?: SessionModelState | null; + readonly modes?: SessionModeState | null; +}; +export const ResumeSessionResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.optionalKey( + Schema.Union([ + Schema.Array(SessionConfigOption).annotate({ + description: "Initial session configuration options if supported by the Agent.", + }), + Schema.Null, + ]), + ), + models: Schema.optionalKey( + Schema.Union([SessionModelState, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent", + }), + ), + modes: Schema.optionalKey( + Schema.Union([SessionModeState, Schema.Null]).annotate({ + description: + "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from resuming an existing session.", +}); + +export type SelectedPermissionOutcome = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly optionId: string; +}; +export const SelectedPermissionOutcome = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + optionId: Schema.String.annotate({ description: "Unique identifier for a permission option." }), +}).annotate({ description: "The user selected one of the provided options." }); + +export type SessionCapabilities = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly close?: SessionCloseCapabilities | null; + readonly fork?: SessionForkCapabilities | null; + readonly list?: SessionListCapabilities | null; + readonly resume?: SessionResumeCapabilities | null; +}; +export const SessionCapabilities = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + close: Schema.optionalKey( + Schema.Union([SessionCloseCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/close`.", + }), + ), + fork: Schema.optionalKey( + Schema.Union([SessionForkCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.", + }), + ), + list: Schema.optionalKey( + Schema.Union([SessionListCapabilities, Schema.Null]).annotate({ + description: "Whether the agent supports `session/list`.", + }), + ), + resume: Schema.optionalKey( + Schema.Union([SessionResumeCapabilities, Schema.Null]).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/resume`.", + }), + ), +}).annotate({ + description: + "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)", +}); + +export type SessionConfigBoolean = { readonly currentValue: boolean }; +export const SessionConfigBoolean = Schema.Struct({ + currentValue: Schema.Boolean.annotate({ + description: "The current value of the boolean option.", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA boolean on/off toggle session configuration option payload.", +}); + +export type SessionConfigGroupId = string; +export const SessionConfigGroupId = Schema.String.annotate({ + description: "Unique identifier for a session configuration option value group.", +}); + +export type SessionConfigId = string; +export const SessionConfigId = Schema.String.annotate({ + description: "Unique identifier for a session configuration option.", +}); + +export type SessionConfigOptionCategory = "mode" | "model" | "thought_level" | string; +export const SessionConfigOptionCategory = Schema.Union([ + Schema.Literal("mode").annotate({ description: "Session mode selector." }), + Schema.Literal("model").annotate({ description: "Model selector." }), + Schema.Literal("thought_level").annotate({ description: "Thought/reasoning level selector." }), + Schema.String.annotate({ title: "other", description: "Unknown / uncategorized selector." }), +]).annotate({ + description: + "Semantic category for a session configuration option.\n\nThis is intended to help Clients distinguish broadly common selectors (e.g. model selector vs\nsession mode selector vs thought/reasoning level) for UX purposes (keyboard shortcuts, icons,\nplacement). It MUST NOT be required for correctness. Clients MUST handle missing or unknown\ncategories gracefully.\n\nCategory names beginning with `_` are free for custom use, like other ACP extension methods.\nCategory names that do not begin with `_` are reserved for the ACP spec.", +}); + +export type SessionConfigSelect = { + readonly currentValue: string; + readonly options: + | ReadonlyArray + | ReadonlyArray; +}; +export const SessionConfigSelect = Schema.Struct({ + currentValue: Schema.String.annotate({ + description: "Unique identifier for a session configuration option value.", + }), + options: Schema.Union([ + Schema.Array(SessionConfigSelectOption).annotate({ + title: "Ungrouped", + description: "A flat list of options with no grouping.", + }), + Schema.Array(SessionConfigSelectGroup).annotate({ + title: "Grouped", + description: "A list of options grouped under headers.", + }), + ]).annotate({ description: "Possible values for a session configuration option." }), +}).annotate({ + description: "A single-value selector (dropdown) session configuration option payload.", +}); + +export type SessionConfigSelectOptions = + | ReadonlyArray + | ReadonlyArray; +export const SessionConfigSelectOptions = Schema.Union([ + Schema.Array(SessionConfigSelectOption).annotate({ + title: "Ungrouped", + description: "A flat list of options with no grouping.", + }), + Schema.Array(SessionConfigSelectGroup).annotate({ + title: "Grouped", + description: "A list of options grouped under headers.", + }), +]).annotate({ description: "Possible values for a session configuration option." }); + +export type SessionConfigValueId = string; +export const SessionConfigValueId = Schema.String.annotate({ + description: "Unique identifier for a session configuration option value.", +}); + +export type SessionId = string; +export const SessionId = Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", +}); + +export type SessionInfoUpdate = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly title?: string | null; + readonly updatedAt?: string | null; +}; +export const SessionInfoUpdate = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Human-readable title for the session. Set to null to clear.", + }), + Schema.Null, + ]), + ), + updatedAt: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "ISO 8601 timestamp of last activity. Set to null to clear.", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "Update to session metadata. All fields are optional to support partial updates.\n\nAgents send this notification to update session information like title or custom metadata.\nThis allows clients to display dynamic session names and track session state changes.", +}); + +export type SessionNotification = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; + readonly update: + | { + readonly sessionUpdate: "user_message_chunk"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + readonly messageId?: string | null; + } + | { + readonly sessionUpdate: "agent_message_chunk"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + readonly messageId?: string | null; + } + | { + readonly sessionUpdate: "agent_thought_chunk"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + readonly messageId?: string | null; + } + | { + readonly sessionUpdate: "tool_call"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content?: ReadonlyArray; + readonly kind?: + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + readonly locations?: ReadonlyArray; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly status?: "pending" | "in_progress" | "completed" | "failed"; + readonly title: string; + readonly toolCallId: string; + } + | { + readonly sessionUpdate: "tool_call_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content?: ReadonlyArray | null; + readonly kind?: ToolKind | null; + readonly locations?: ReadonlyArray | null; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly status?: ToolCallStatus | null; + readonly title?: string | null; + readonly toolCallId: string; + } + | { + readonly sessionUpdate: "plan"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly entries: ReadonlyArray; + } + | { + readonly sessionUpdate: "available_commands_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly availableCommands: ReadonlyArray; + } + | { + readonly sessionUpdate: "current_mode_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly currentModeId: string; + } + | { + readonly sessionUpdate: "config_option_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions: ReadonlyArray; + } + | { + readonly sessionUpdate: "session_info_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly title?: string | null; + readonly updatedAt?: string | null; + } + | { + readonly sessionUpdate: "usage_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cost?: Cost | null; + readonly size: number; + readonly used: number; + }; +}; +export const SessionNotification = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + update: Schema.Union( + [ + Schema.Struct({ + sessionUpdate: Schema.Literal("user_message_chunk"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: + "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + }).annotate({ description: "A streamed item of content" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_message_chunk"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: + "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + }).annotate({ description: "A streamed item of content" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_thought_chunk"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: + "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + }).annotate({ description: "A streamed item of content" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.optionalKey( + Schema.Array(ToolCallContent).annotate({ + description: "Content produced by the tool call.", + }), + ), + kind: Schema.optionalKey( + Schema.Literals([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", + ]).annotate({ + description: + "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)", + }), + ), + locations: Schema.optionalKey( + Schema.Array(ToolCallLocation).annotate({ + description: + 'File locations affected by this tool call.\nEnables "follow-along" features in clients.', + }), + ), + rawInput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Raw input parameters sent to the tool." }), + ), + rawOutput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Raw output returned by the tool." }), + ), + status: Schema.optionalKey( + Schema.Literals(["pending", "in_progress", "completed", "failed"]).annotate({ + description: + "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)", + }), + ), + title: Schema.String.annotate({ + description: "Human-readable title describing what the tool is doing.", + }), + toolCallId: Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", + }), + }).annotate({ + description: + "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallContent).annotate({ + description: "Replace the content collection.", + }), + Schema.Null, + ]), + ), + kind: Schema.optionalKey( + Schema.Union([ToolKind, Schema.Null]).annotate({ description: "Update the tool kind." }), + ), + locations: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallLocation).annotate({ + description: "Replace the locations collection.", + }), + Schema.Null, + ]), + ), + rawInput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Update the raw input." }), + ), + rawOutput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Update the raw output." }), + ), + status: Schema.optionalKey( + Schema.Union([ToolCallStatus, Schema.Null]).annotate({ + description: "Update the execution status.", + }), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Update the human-readable title." }), + Schema.Null, + ]), + ), + toolCallId: Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", + }), + }).annotate({ + description: + "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("plan"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + entries: Schema.Array(PlanEntry).annotate({ + description: + "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", + }), + }).annotate({ + description: + "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("available_commands_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + availableCommands: Schema.Array(AvailableCommand).annotate({ + description: "Commands the agent can execute", + }), + }).annotate({ description: "Available commands are ready or have changed" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("current_mode_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + currentModeId: Schema.String.annotate({ + description: "Unique identifier for a Session Mode.", + }), + }).annotate({ + description: + "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("config_option_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.Array(SessionConfigOption).annotate({ + description: "The full set of configuration options and their current values.", + }), + }).annotate({ description: "Session configuration options have been updated." }), + Schema.Struct({ + sessionUpdate: Schema.Literal("session_info_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Human-readable title for the session. Set to null to clear.", + }), + Schema.Null, + ]), + ), + updatedAt: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "ISO 8601 timestamp of last activity. Set to null to clear.", + }), + Schema.Null, + ]), + ), + }).annotate({ + description: + "Update to session metadata. All fields are optional to support partial updates.\n\nAgents send this notification to update session information like title or custom metadata.\nThis allows clients to display dynamic session names and track session state changes.", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("usage_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cost: Schema.optionalKey( + Schema.Union([Cost, Schema.Null]).annotate({ + description: "Cumulative session cost (optional).", + }), + ), + size: Schema.Number.annotate({ + description: "Total context window size in tokens.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + used: Schema.Number.annotate({ + description: "Tokens currently in context.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nContext window and cost update for a session.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Different types of updates that can be sent during session processing.\n\nThese updates provide real-time feedback about the agent's progress.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + }), +}).annotate({ + description: + "Notification containing a session update from the agent.\n\nUsed to stream real-time progress and results during prompt processing.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", +}); + +export type SessionUpdate = + | { + readonly sessionUpdate: "user_message_chunk"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + readonly messageId?: string | null; + } + | { + readonly sessionUpdate: "agent_message_chunk"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + readonly messageId?: string | null; + } + | { + readonly sessionUpdate: "agent_thought_chunk"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: + | { + readonly type: "text"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; + } + | { + readonly type: "image"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + readonly uri?: string | null; + } + | { + readonly type: "audio"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly data: string; + readonly mimeType: string; + } + | { + readonly type: "resource_link"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly description?: string | null; + readonly mimeType?: string | null; + readonly name: string; + readonly size?: number | null; + readonly title?: string | null; + readonly uri: string; + } + | { + readonly type: "resource"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly resource: EmbeddedResourceResource; + }; + readonly messageId?: string | null; + } + | { + readonly sessionUpdate: "tool_call"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content?: ReadonlyArray; + readonly kind?: + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + readonly locations?: ReadonlyArray; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly status?: "pending" | "in_progress" | "completed" | "failed"; + readonly title: string; + readonly toolCallId: string; + } + | { + readonly sessionUpdate: "tool_call_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content?: ReadonlyArray | null; + readonly kind?: ToolKind | null; + readonly locations?: ReadonlyArray | null; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly status?: ToolCallStatus | null; + readonly title?: string | null; + readonly toolCallId: string; + } + | { + readonly sessionUpdate: "plan"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly entries: ReadonlyArray; + } + | { + readonly sessionUpdate: "available_commands_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly availableCommands: ReadonlyArray; + } + | { + readonly sessionUpdate: "current_mode_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly currentModeId: string; + } + | { + readonly sessionUpdate: "config_option_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions: ReadonlyArray; + } + | { + readonly sessionUpdate: "session_info_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly title?: string | null; + readonly updatedAt?: string | null; + } + | { + readonly sessionUpdate: "usage_update"; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cost?: Cost | null; + readonly size: number; + readonly used: number; + }; +export const SessionUpdate = Schema.Union( + [ + Schema.Struct({ + sessionUpdate: Schema.Literal("user_message_chunk"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + }).annotate({ description: "A streamed item of content" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_message_chunk"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + }).annotate({ description: "A streamed item of content" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_thought_chunk"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.Union( + [ + Schema.Struct({ + type: Schema.Literal("text"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, + }).annotate({ description: "Text provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("image"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + }).annotate({ description: "An image provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("audio"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + data: Schema.String, + mimeType: Schema.String, + }).annotate({ description: "Audio provided to or from an LLM." }), + Schema.Struct({ + type: Schema.Literal("resource_link"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + name: Schema.String, + size: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), + Schema.Null, + ]), + ), + title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, + }).annotate({ + description: + "A resource that the server is capable of reading, included in a prompt or tool call result.", + }), + Schema.Struct({ + type: Schema.Literal("resource"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + resource: EmbeddedResourceResource, + }).annotate({ + description: "The contents of a resource, embedded into a prompt or tool call result.", + }), + ], + { mode: "oneOf" }, + ).annotate({ + description: + "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + }), + messageId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.", + }), + Schema.Null, + ]), + ), + }).annotate({ description: "A streamed item of content" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.optionalKey( + Schema.Array(ToolCallContent).annotate({ + description: "Content produced by the tool call.", + }), + ), + kind: Schema.optionalKey( + Schema.Literals([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", + ]).annotate({ + description: + "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)", + }), + ), + locations: Schema.optionalKey( + Schema.Array(ToolCallLocation).annotate({ + description: + 'File locations affected by this tool call.\nEnables "follow-along" features in clients.', + }), + ), + rawInput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Raw input parameters sent to the tool." }), + ), + rawOutput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Raw output returned by the tool." }), + ), + status: Schema.optionalKey( + Schema.Literals(["pending", "in_progress", "completed", "failed"]).annotate({ + description: + "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)", + }), + ), + title: Schema.String.annotate({ + description: "Human-readable title describing what the tool is doing.", + }), + toolCallId: Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", + }), + }).annotate({ + description: + "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallContent).annotate({ + description: "Replace the content collection.", + }), + Schema.Null, + ]), + ), + kind: Schema.optionalKey( + Schema.Union([ToolKind, Schema.Null]).annotate({ description: "Update the tool kind." }), + ), + locations: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallLocation).annotate({ + description: "Replace the locations collection.", + }), + Schema.Null, + ]), + ), + rawInput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Update the raw input." }), + ), + rawOutput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Update the raw output." }), + ), + status: Schema.optionalKey( + Schema.Union([ToolCallStatus, Schema.Null]).annotate({ + description: "Update the execution status.", + }), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Update the human-readable title." }), + Schema.Null, + ]), + ), + toolCallId: Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", + }), + }).annotate({ + description: + "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("plan"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + entries: Schema.Array(PlanEntry).annotate({ + description: + "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", + }), + }).annotate({ + description: + "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("available_commands_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + availableCommands: Schema.Array(AvailableCommand).annotate({ + description: "Commands the agent can execute", + }), + }).annotate({ description: "Available commands are ready or have changed" }), + Schema.Struct({ + sessionUpdate: Schema.Literal("current_mode_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + currentModeId: Schema.String.annotate({ + description: "Unique identifier for a Session Mode.", + }), + }).annotate({ + description: + "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("config_option_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.Array(SessionConfigOption).annotate({ + description: "The full set of configuration options and their current values.", + }), + }).annotate({ description: "Session configuration options have been updated." }), + Schema.Struct({ + sessionUpdate: Schema.Literal("session_info_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Human-readable title for the session. Set to null to clear.", + }), + Schema.Null, + ]), + ), + updatedAt: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "ISO 8601 timestamp of last activity. Set to null to clear.", + }), + Schema.Null, + ]), + ), + }).annotate({ + description: + "Update to session metadata. All fields are optional to support partial updates.\n\nAgents send this notification to update session information like title or custom metadata.\nThis allows clients to display dynamic session names and track session state changes.", + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("usage_update"), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cost: Schema.optionalKey( + Schema.Union([Cost, Schema.Null]).annotate({ + description: "Cumulative session cost (optional).", + }), + ), + size: Schema.Number.annotate({ + description: "Total context window size in tokens.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + used: Schema.Number.annotate({ + description: "Tokens currently in context.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nContext window and cost update for a session.", + }), + ], + { mode: "oneOf" }, +).annotate({ + description: + "Different types of updates that can be sent during session processing.\n\nThese updates provide real-time feedback about the agent's progress.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", +}); + +export type SetSessionConfigOptionRequest = + | { readonly type: "boolean"; readonly value: boolean } + | { readonly value: string }; +export const SetSessionConfigOptionRequest = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("boolean"), + value: Schema.Boolean.annotate({ description: "The boolean value." }), + }).annotate({ description: 'A boolean value (`type: "boolean"`).' }), + Schema.Struct({ + value: Schema.String.annotate({ + description: "Unique identifier for a session configuration option value.", + }), + }).annotate({ + title: "value_id", + description: + "A [`SessionConfigValueId`] string value.\n\nThis is the default when `type` is absent on the wire. Unknown `type`\nvalues with string payloads also gracefully deserialize into this\nvariant.", + }), +]).annotate({ description: "Request parameters for setting a session configuration option." }); + +export type SetSessionConfigOptionResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configOptions: ReadonlyArray; +}; +export const SetSessionConfigOptionResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configOptions: Schema.Array(SessionConfigOption).annotate({ + description: "The full set of configuration options and their current values.", + }), +}).annotate({ description: "Response to `session/set_config_option` method." }); + +export type SetSessionModelRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly modelId: string; + readonly sessionId: string; +}; +export const SetSessionModelRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + modelId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for a model.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for setting a session model.", +}); + +export type SetSessionModelResponse = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const SetSessionModelResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse to `session/set_model` method.", +}); + +export type SetSessionModeRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly modeId: string; + readonly sessionId: string; +}; +export const SetSessionModeRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + modeId: Schema.String.annotate({ description: "Unique identifier for a Session Mode." }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ description: "Request parameters for setting a session mode." }); + +export type SetSessionModeResponse = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const SetSessionModeResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Response to `session/set_mode` method." }); + +export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled"; +export const StopReason = Schema.Literals([ + "end_turn", + "max_tokens", + "max_turn_requests", + "refusal", + "cancelled", +]).annotate({ + description: + "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)", +}); + +export type StringPropertySchema = { + readonly default?: string | null; + readonly description?: string | null; + readonly enum?: ReadonlyArray | null; + readonly format?: StringFormat | null; + readonly maxLength?: number | null; + readonly minLength?: number | null; + readonly oneOf?: ReadonlyArray | null; + readonly pattern?: string | null; + readonly title?: string | null; +}; +export const StringPropertySchema = Schema.Struct({ + default: Schema.optionalKey( + Schema.Union([Schema.String.annotate({ description: "Default value." }), Schema.Null]), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Human-readable description." }), + Schema.Null, + ]), + ), + enum: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.String).annotate({ + description: "Enum values for untitled single-select enums.", + }), + Schema.Null, + ]), + ), + format: Schema.optionalKey( + Schema.Union([StringFormat, Schema.Null]).annotate({ description: "String format." }), + ), + maxLength: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Maximum string length.", format: "uint32" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + minLength: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Minimum string length.", format: "uint32" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + oneOf: Schema.optionalKey( + Schema.Union([ + Schema.Array(EnumOption).annotate({ + description: "Titled enum options for titled single-select enums.", + }), + Schema.Null, + ]), + ), + pattern: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Pattern the string must match." }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the property." }), + Schema.Null, + ]), + ), +}).annotate({ + description: + 'Schema for string properties in an elicitation form.\n\nWhen `enum` or `oneOf` is set, this represents a single-select enum\nwith `"type": "string"`.', +}); + +export type Terminal = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly terminalId: string; +}; +export const Terminal = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + terminalId: Schema.String, +}).annotate({ + description: + "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)", +}); + +export type TerminalOutputRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; + readonly terminalId: string; +}; +export const TerminalOutputRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + terminalId: Schema.String.annotate({ description: "The ID of the terminal to get output from." }), +}).annotate({ description: "Request to get the current output and status of a terminal." }); + +export type TerminalOutputResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly exitStatus?: TerminalExitStatus | null; + readonly output: string; + readonly truncated: boolean; +}; +export const TerminalOutputResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + exitStatus: Schema.optionalKey( + Schema.Union([TerminalExitStatus, Schema.Null]).annotate({ + description: "Exit status if the command has completed.", + }), + ), + output: Schema.String.annotate({ description: "The terminal output captured so far." }), + truncated: Schema.Boolean.annotate({ + description: "Whether the output was truncated due to byte limits.", + }), +}).annotate({ description: "Response containing the terminal output and exit status." }); + +export type TextContent = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly annotations?: Annotations | null; + readonly text: string; +}; +export const TextContent = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])), + text: Schema.String, +}).annotate({ description: "Text provided to or from an LLM." }); + +export type TextResourceContents = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly mimeType?: string | null; + readonly text: string; + readonly uri: string; +}; +export const TextResourceContents = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + text: Schema.String, + uri: Schema.String, +}).annotate({ description: "Text-based resource contents." }); + +export type TitledMultiSelectItems = { readonly anyOf: ReadonlyArray }; +export const TitledMultiSelectItems = Schema.Struct({ + anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }), +}).annotate({ description: "Items definition for titled multi-select enum properties." }); + +export type ToolCall = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content?: ReadonlyArray; + readonly kind?: + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + readonly locations?: ReadonlyArray; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly status?: "pending" | "in_progress" | "completed" | "failed"; + readonly title: string; + readonly toolCallId: string; +}; +export const ToolCall = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.optionalKey( + Schema.Array(ToolCallContent).annotate({ description: "Content produced by the tool call." }), + ), + kind: Schema.optionalKey( + Schema.Literals([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", + ]).annotate({ + description: + "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)", + }), + ), + locations: Schema.optionalKey( + Schema.Array(ToolCallLocation).annotate({ + description: + 'File locations affected by this tool call.\nEnables "follow-along" features in clients.', + }), + ), + rawInput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Raw input parameters sent to the tool." }), + ), + rawOutput: Schema.optionalKey( + Schema.Unknown.annotate({ description: "Raw output returned by the tool." }), + ), + status: Schema.optionalKey( + Schema.Literals(["pending", "in_progress", "completed", "failed"]).annotate({ + description: + "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)", + }), + ), + title: Schema.String.annotate({ + description: "Human-readable title describing what the tool is doing.", + }), + toolCallId: Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", + }), +}).annotate({ + description: + "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)", +}); + +export type ToolCallId = string; +export const ToolCallId = Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", +}); + +export type ToolCallUpdate = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content?: ReadonlyArray | null; + readonly kind?: ToolKind | null; + readonly locations?: ReadonlyArray | null; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly status?: ToolCallStatus | null; + readonly title?: string | null; + readonly toolCallId: string; +}; +export const ToolCallUpdate = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallContent).annotate({ description: "Replace the content collection." }), + Schema.Null, + ]), + ), + kind: Schema.optionalKey( + Schema.Union([ToolKind, Schema.Null]).annotate({ description: "Update the tool kind." }), + ), + locations: Schema.optionalKey( + Schema.Union([ + Schema.Array(ToolCallLocation).annotate({ description: "Replace the locations collection." }), + Schema.Null, + ]), + ), + rawInput: Schema.optionalKey(Schema.Unknown.annotate({ description: "Update the raw input." })), + rawOutput: Schema.optionalKey(Schema.Unknown.annotate({ description: "Update the raw output." })), + status: Schema.optionalKey( + Schema.Union([ToolCallStatus, Schema.Null]).annotate({ + description: "Update the execution status.", + }), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Update the human-readable title." }), + Schema.Null, + ]), + ), + toolCallId: Schema.String.annotate({ + description: "Unique identifier for a tool call within a session.", + }), +}).annotate({ + description: + "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", +}); + +export type UnstructuredCommandInput = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly hint: string; +}; +export const UnstructuredCommandInput = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + hint: Schema.String.annotate({ + description: "A hint to display when the input hasn't been provided yet", + }), +}).annotate({ + description: "All text that was typed after the command name is provided as input.", +}); + +export type UntitledMultiSelectItems = { + readonly enum: ReadonlyArray; + readonly type: "string"; +}; +export const UntitledMultiSelectItems = Schema.Struct({ + enum: Schema.Array(Schema.String).annotate({ description: "Allowed enum values." }), + type: Schema.Literal("string").annotate({ + description: "Items definition for untitled multi-select enum properties.", + }), +}).annotate({ description: "Items definition for untitled multi-select enum properties." }); + +export type UsageUpdate = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly cost?: Cost | null; + readonly size: number; + readonly used: number; +}; +export const UsageUpdate = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + cost: Schema.optionalKey( + Schema.Union([Cost, Schema.Null]).annotate({ + description: "Cumulative session cost (optional).", + }), + ), + size: Schema.Number.annotate({ + description: "Total context window size in tokens.", + format: "uint64", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + used: Schema.Number.annotate({ description: "Tokens currently in context.", format: "uint64" }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), +}).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nContext window and cost update for a session.", +}); + +export type WaitForTerminalExitRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly sessionId: string; + readonly terminalId: string; +}; +export const WaitForTerminalExitRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + terminalId: Schema.String.annotate({ description: "The ID of the terminal to wait for." }), +}).annotate({ description: "Request to wait for a terminal command to exit." }); + +export type WaitForTerminalExitResponse = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly exitCode?: number | null; + readonly signal?: string | null; +}; +export const WaitForTerminalExitResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + exitCode: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: "The process exit code (may be null if terminated by signal).", + format: "uint32", + }) + .check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)), + Schema.Null, + ]), + ), + signal: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "The signal that terminated the process (may be null if exited normally).", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Response containing the exit status of a terminal command." }); + +export type WriteTextFileRequest = { + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly content: string; + readonly path: string; + readonly sessionId: string; +}; +export const WriteTextFileRequest = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + content: Schema.String.annotate({ description: "The text content to write to the file." }), + path: Schema.String.annotate({ description: "Absolute path to the file to write." }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), +}).annotate({ + description: + "Request to write content to a text file.\n\nOnly available if the client supports the `fs.writeTextFile` capability.", +}); + +export type WriteTextFileResponse = { readonly _meta?: { readonly [x: string]: unknown } | null }; +export const WriteTextFileResponse = Schema.Struct({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Response to `fs/write_text_file`" }); diff --git a/packages/effect-acp/src/child-process.ts b/packages/effect-acp/src/child-process.ts new file mode 100644 index 00000000000..f9aaf971bb0 --- /dev/null +++ b/packages/effect-acp/src/child-process.ts @@ -0,0 +1,21 @@ +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stdio from "effect/Stdio"; +import type { ChildProcessSpawner } from "effect/unstable/process"; + +const textEncoder = new TextEncoder(); + +export function makeStdioFromChildProcess( + handle: ChildProcessSpawner.ChildProcessHandle, +): Stdio.Stdio { + return Stdio.make({ + stdin: handle.stdout, + stdout: Sink.mapInput(handle.stdin, (chunk) => + typeof chunk === "string" ? textEncoder.encode(chunk) : chunk, + ), + stderr: Sink.drain, + }); +} + +export const layerStdioFromChildProcess = (handle: ChildProcessSpawner.ChildProcessHandle) => + Layer.succeed(Stdio.Stdio, makeStdioFromChildProcess(handle)); diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts new file mode 100644 index 00000000000..d9208503105 --- /dev/null +++ b/packages/effect-acp/src/client.test.ts @@ -0,0 +1,100 @@ +import * as Path from "effect/Path"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, assert } from "@effect/vitest"; + +import * as AcpClient from "./client"; + +const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => + path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), +); + +it.layer(NodeServices.layer)("effect-acp client", (it) => { + it.effect("initializes, prompts, receives updates, and handles permission requests", () => + Effect.gen(function* () { + const updates = yield* Ref.make>([]); + const elicitationCompletions = yield* Ref.make>([]); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + + const command = ChildProcess.make("bun", ["run", yield* mockPeerPath], { + cwd: path.join(import.meta.dirname, ".."), + shell: process.platform === "win32", + }); + const handle = yield* spawner.spawn(command); + + const client = yield* AcpClient.fromChildProcess(handle, { + handlers: { + requestPermission: () => + Effect.succeed({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }), + elicitation: () => + Effect.succeed({ + action: { + action: "accept", + content: { + approved: true, + }, + }, + }), + sessionUpdate: (notification) => + Ref.update(updates, (current) => [...current, notification]), + elicitationComplete: (notification) => + Ref.update(elicitationCompletions, (current) => [...current, notification]), + }, + }); + + const init = yield* client.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: "effect-acp-test", + version: "0.0.0", + }, + }); + assert.equal(init.protocolVersion, 1); + + yield* client.authenticate({ methodId: "cursor_login" }); + + const session = yield* client.createSession({ + cwd: process.cwd(), + mcpServers: [], + }); + assert.equal(session.sessionId, "mock-session-1"); + + const prompt = yield* client.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "hello" }], + }); + assert.equal(prompt.stopReason, "end_turn"); + + const streamed = yield* Stream.runCollect(Stream.take(client.updates, 2)); + assert.equal(streamed.length, 2); + assert.equal(streamed[0]?._tag, "SessionUpdate"); + assert.equal(streamed[1]?._tag, "ElicitationComplete"); + assert.equal((yield* Ref.get(updates)).length, 1); + assert.equal((yield* Ref.get(elicitationCompletions)).length, 1); + + const ext = yield* client.extRequest("x/echo", { + hello: "world", + }); + assert.deepEqual(ext, { + echoedMethod: "x/echo", + echoedParams: { + hello: "world", + }, + }); + }), + ); +}); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts new file mode 100644 index 00000000000..53c2ca0fd6d --- /dev/null +++ b/packages/effect-acp/src/client.ts @@ -0,0 +1,356 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcServer from "effect/unstable/rpc/RpcServer"; + +import { makeStdioFromChildProcess } from "./child-process"; +import * as AcpError from "./errors"; +import * as AcpProtocol from "./protocol"; +import * as AcpRpcs from "./rpc"; +import * as AcpServer from "./server"; +import * as AcpSchema from "./_generated/schema.gen"; +import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export interface AcpClientHandlers { + /** + * Handles `session/request_permission`. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission + */ + readonly requestPermission?: ( + request: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect; + /** + * Handles `session/elicitation`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation + */ + readonly elicitation?: ( + request: AcpSchema.ElicitationRequest, + ) => Effect.Effect; + /** + * Handles `fs/read_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file + */ + readonly readTextFile?: ( + request: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect; + /** + * Handles `fs/write_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + */ + readonly writeTextFile?: ( + request: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect; + /** + * Handles `terminal/create`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create + */ + readonly createTerminal?: ( + request: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect; + /** + * Handles `terminal/output`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/output + */ + readonly terminalOutput?: ( + request: AcpSchema.TerminalOutputRequest, + ) => Effect.Effect; + /** + * Handles `terminal/wait_for_exit`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit + */ + readonly terminalWaitForExit?: ( + request: AcpSchema.WaitForTerminalExitRequest, + ) => Effect.Effect; + /** + * Handles `terminal/kill`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/kill + */ + readonly terminalKill?: ( + request: AcpSchema.KillTerminalRequest, + ) => Effect.Effect; + /** + * Handles `terminal/release`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/release + */ + readonly terminalRelease?: ( + request: AcpSchema.ReleaseTerminalRequest, + ) => Effect.Effect; + /** + * Handles `session/update` notifications from the agent. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly sessionUpdate?: ( + notification: AcpSchema.SessionNotification, + ) => Effect.Effect; + /** + * Handles `session/elicitation/complete` notifications from the agent. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete + */ + readonly elicitationComplete?: ( + notification: AcpSchema.ElicitationCompleteNotification, + ) => Effect.Effect; + /** + * Handles extension requests outside the core ACP method set. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extRequest?: ( + method: string, + params: unknown, + ) => Effect.Effect; + /** + * Handles extension notifications outside the core ACP method set. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extNotification?: ( + method: string, + params: unknown, + ) => Effect.Effect; +} + +export interface AcpClientConnectOptions { + readonly command: ChildProcess.Command; + readonly handlers?: AcpClientHandlers; +} + +export interface AcpClientConnection { + readonly process: ChildProcessSpawner.ChildProcessHandle; + /** + * Stream of inbound ACP notifications observed on the connection. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly updates: Stream.Stream; + readonly server: AcpServer.AcpServerConnection; + /** + * Initializes the ACP session and negotiates capabilities. + * @see https://agentclientprotocol.com/protocol/schema#initialize + */ + readonly initialize: ( + payload: AcpSchema.InitializeRequest, + ) => Effect.Effect; + /** + * Performs ACP authentication when the agent requires it. + * @see https://agentclientprotocol.com/protocol/schema#authenticate + */ + readonly authenticate: ( + payload: AcpSchema.AuthenticateRequest, + ) => Effect.Effect; + /** + * Logs out the current ACP identity. + * @see https://agentclientprotocol.com/protocol/schema#logout + */ + readonly logout: ( + payload: AcpSchema.LogoutRequest, + ) => Effect.Effect; + /** + * Starts a new ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/new + */ + readonly createSession: ( + payload: AcpSchema.NewSessionRequest, + ) => Effect.Effect; + /** Loads a previously saved ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/load + */ + readonly loadSession: ( + payload: AcpSchema.LoadSessionRequest, + ) => Effect.Effect; + /** + * Lists available ACP sessions. + * @see https://agentclientprotocol.com/protocol/schema#session/list + */ + readonly listSessions: ( + payload: AcpSchema.ListSessionsRequest, + ) => Effect.Effect; + /** + * Forks an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/fork + */ + readonly forkSession: ( + payload: AcpSchema.ForkSessionRequest, + ) => Effect.Effect; + /** + * Resumes an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/resume + */ + readonly resumeSession: ( + payload: AcpSchema.ResumeSessionRequest, + ) => Effect.Effect; + /** + * Closes an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/close + */ + readonly closeSession: ( + payload: AcpSchema.CloseSessionRequest, + ) => Effect.Effect; + /** + * Changes the current session mode. + * @see https://agentclientprotocol.com/protocol/schema#session/set_mode + */ + readonly setSessionMode: ( + payload: AcpSchema.SetSessionModeRequest, + ) => Effect.Effect; + /** + * Selects the active model for a session. + * @see https://agentclientprotocol.com/protocol/schema#session/set_model + */ + readonly setSessionModel: ( + payload: AcpSchema.SetSessionModelRequest, + ) => Effect.Effect; + /** + * Updates a session configuration option. + * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + */ + readonly setSessionConfigOption: ( + payload: AcpSchema.SetSessionConfigOptionRequest, + ) => Effect.Effect; + /** + * Sends a prompt turn to the agent. + * @see https://agentclientprotocol.com/protocol/schema#session/prompt + */ + readonly prompt: ( + payload: AcpSchema.PromptRequest, + ) => Effect.Effect; + /** + * Sends a real ACP `session/cancel` notification. + * @see https://agentclientprotocol.com/protocol/schema#session/cancel + */ + readonly cancel: ( + payload: AcpSchema.CancelNotification, + ) => Effect.Effect; + /** + * Sends an ACP extension request. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extRequest: ( + method: string, + payload: unknown, + ) => Effect.Effect; + /** + * Sends an ACP extension notification. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extNotification: ( + method: string, + payload: unknown, + ) => Effect.Effect; +} + +export const fromChildProcess = Effect.fnUntraced(function* ( + handle: ChildProcessSpawner.ChildProcessHandle, + options: { + readonly handlers?: AcpClientHandlers; + } = {}, +): Effect.fn.Return { + const handlers = options.handlers ?? {}; + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio: makeStdioFromChildProcess(handle), + serverRequestMethods: new Set(AcpRpcs.ClientRpcs.requests.keys()), + onNotification: (notification) => { + switch (notification._tag) { + case "SessionUpdate": + return handlers.sessionUpdate ? handlers.sessionUpdate(notification.params) : Effect.void; + case "ElicitationComplete": + return handlers.elicitationComplete + ? handlers.elicitationComplete(notification.params) + : Effect.void; + case "ExtNotification": + return handlers.extNotification + ? handlers.extNotification(notification.method, notification.params) + : Effect.void; + case "SessionCancel": + return handlers.extNotification + ? handlers.extNotification(notification.method, notification.params) + : Effect.void; + } + }, + ...(handlers.extRequest ? { onExtRequest: handlers.extRequest } : {}), + }); + + const clientHandlerLayer = AcpRpcs.ClientRpcs.toLayer( + AcpRpcs.ClientRpcs.of({ + [CLIENT_METHODS.session_request_permission]: (payload) => + runHandler(handlers.requestPermission, payload, CLIENT_METHODS.session_request_permission), + [CLIENT_METHODS.session_elicitation]: (payload) => + runHandler(handlers.elicitation, payload, CLIENT_METHODS.session_elicitation), + [CLIENT_METHODS.fs_read_text_file]: (payload) => + runHandler(handlers.readTextFile, payload, CLIENT_METHODS.fs_read_text_file), + [CLIENT_METHODS.fs_write_text_file]: (payload) => + runHandler(handlers.writeTextFile, payload, CLIENT_METHODS.fs_write_text_file).pipe( + Effect.map((result) => result ?? {}), + ), + [CLIENT_METHODS.terminal_create]: (payload) => + runHandler(handlers.createTerminal, payload, CLIENT_METHODS.terminal_create), + [CLIENT_METHODS.terminal_output]: (payload) => + runHandler(handlers.terminalOutput, payload, CLIENT_METHODS.terminal_output), + [CLIENT_METHODS.terminal_wait_for_exit]: (payload) => + runHandler(handlers.terminalWaitForExit, payload, CLIENT_METHODS.terminal_wait_for_exit), + [CLIENT_METHODS.terminal_kill]: (payload) => + runHandler(handlers.terminalKill, payload, CLIENT_METHODS.terminal_kill).pipe( + Effect.map((result) => result ?? {}), + ), + [CLIENT_METHODS.terminal_release]: (payload) => + runHandler(handlers.terminalRelease, payload, CLIENT_METHODS.terminal_release).pipe( + Effect.map((result) => result ?? {}), + ), + }), + ); + + yield* RpcServer.make(AcpRpcs.ClientRpcs).pipe( + Effect.provideService(RpcServer.Protocol, transport.serverProtocol), + Effect.provide(clientHandlerLayer), + Effect.forkScoped, + ); + + const rpc = yield* RpcClient.make(AcpRpcs.AgentRpcs).pipe( + Effect.provideService(RpcClient.Protocol, transport.clientProtocol), + ); + + const callRpc = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(AcpError.normalizeAcpError)); + + const server = AcpServer.makeAcpServerConnection(transport); + + return { + process: handle, + updates: transport.notifications.incoming, + server, + initialize: (payload) => callRpc(rpc[AGENT_METHODS.initialize](payload)), + authenticate: (payload) => callRpc(rpc[AGENT_METHODS.authenticate](payload)), + logout: (payload) => callRpc(rpc[AGENT_METHODS.logout](payload)), + createSession: (payload) => callRpc(rpc[AGENT_METHODS.session_new](payload)), + loadSession: (payload) => callRpc(rpc[AGENT_METHODS.session_load](payload)), + listSessions: (payload) => callRpc(rpc[AGENT_METHODS.session_list](payload)), + forkSession: (payload) => callRpc(rpc[AGENT_METHODS.session_fork](payload)), + resumeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_resume](payload)), + closeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_close](payload)), + setSessionMode: (payload) => callRpc(rpc[AGENT_METHODS.session_set_mode](payload)), + setSessionModel: (payload) => callRpc(rpc[AGENT_METHODS.session_set_model](payload)), + setSessionConfigOption: (payload) => + callRpc(rpc[AGENT_METHODS.session_set_config_option](payload)), + prompt: (payload) => callRpc(rpc[AGENT_METHODS.session_prompt](payload)), + cancel: (payload) => transport.notifications.sendSessionCancel(payload), + extRequest: transport.sendRequest, + extNotification: transport.notifications.sendExtNotification, + } satisfies AcpClientConnection; +}); + +const runHandler = Effect.fnUntraced(function* ( + handler: ((payload: A) => Effect.Effect) | undefined, + payload: A, + method: string, +) { + if (!handler) { + return yield* AcpError.AcpRequestError.methodNotFound(method); + } + return yield* handler(payload).pipe( + Effect.mapError((error) => { + const normalized = AcpError.normalizeAcpError(error); + return Schema.is(AcpError.AcpRequestError)(normalized) + ? normalized.toProtocolError() + : AcpError.AcpRequestError.internalError(normalized.message).toProtocolError(); + }), + ); +}); diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts new file mode 100644 index 00000000000..0c216f915de --- /dev/null +++ b/packages/effect-acp/src/errors.ts @@ -0,0 +1,182 @@ +import * as Schema from "effect/Schema"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; + +import * as AcpSchema from "./_generated/schema.gen"; + +export type AcpProtocolError = AcpSchema.Error; + +export class AcpSpawnError extends Schema.TaggedErrorClass()("AcpSpawnError", { + command: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect), +}) { + override get message() { + return this.command + ? `Failed to spawn ACP process for command: ${this.command}` + : "Failed to spawn ACP process"; + } +} + +export class AcpProcessExitedError extends Schema.TaggedErrorClass()( + "AcpProcessExitedError", + { + code: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message() { + return this.code === undefined + ? "ACP process exited unexpectedly" + : `ACP process exited unexpectedly with code ${this.code}`; + } +} + +export class AcpProtocolParseError extends Schema.TaggedErrorClass()( + "AcpProtocolParseError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message() { + return `Failed to parse ACP protocol message: ${this.detail}`; + } +} + +export class AcpTransportError extends Schema.TaggedErrorClass()( + "AcpTransportError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message() { + return this.detail; + } +} + +export class AcpRequestError extends Schema.TaggedErrorClass()("AcpRequestError", { + code: Schema.Number, + errorMessage: Schema.String, + data: Schema.optional(Schema.Unknown), +}) { + override get message() { + return this.errorMessage; + } + + static fromProtocolError(error: AcpProtocolError) { + return new AcpRequestError({ + code: error.code, + errorMessage: error.message, + ...(error.data !== undefined ? { data: error.data } : {}), + }); + } + + static parseError(message = "Parse error", data?: unknown) { + return new AcpRequestError({ + code: -32700, + errorMessage: message, + ...(data !== undefined ? { data } : {}), + }); + } + + static invalidRequest(message = "Invalid request", data?: unknown) { + return new AcpRequestError({ + code: -32600, + errorMessage: message, + ...(data !== undefined ? { data } : {}), + }); + } + + static methodNotFound(method: string) { + return new AcpRequestError({ + code: -32601, + errorMessage: `Method not found: ${method}`, + }); + } + + static invalidParams(message = "Invalid params", data?: unknown) { + return new AcpRequestError({ + code: -32602, + errorMessage: message, + ...(data !== undefined ? { data } : {}), + }); + } + + static internalError(message = "Internal error", data?: unknown) { + return new AcpRequestError({ + code: -32603, + errorMessage: message, + ...(data !== undefined ? { data } : {}), + }); + } + + static authRequired(message = "Authentication required", data?: unknown) { + return new AcpRequestError({ + code: -32000, + errorMessage: message, + ...(data !== undefined ? { data } : {}), + }); + } + + static resourceNotFound(message = "Resource not found", data?: unknown) { + return new AcpRequestError({ + code: -32002, + errorMessage: message, + ...(data !== undefined ? { data } : {}), + }); + } + + toProtocolError(): AcpProtocolError { + return { + code: this.code, + message: this.errorMessage, + ...(this.data !== undefined ? { data: this.data } : {}), + }; + } +} + +export type AcpError = + | AcpRequestError + | AcpSpawnError + | AcpProcessExitedError + | AcpProtocolParseError + | AcpTransportError; + +export function normalizeAcpError(error: unknown): AcpError { + if ( + Schema.is(AcpRequestError)(error) || + Schema.is(AcpSpawnError)(error) || + Schema.is(AcpProcessExitedError)(error) || + Schema.is(AcpProtocolParseError)(error) || + Schema.is(AcpTransportError)(error) + ) { + return error; + } + + if (Schema.is(RpcClientError.RpcClientError)(error)) { + return new AcpTransportError({ + detail: error.message, + cause: error, + }); + } + + if (isProtocolError(error)) { + return AcpRequestError.fromProtocolError(error); + } + + return new AcpTransportError({ + detail: error instanceof Error ? error.message : String(error), + ...(error !== undefined ? { cause: error } : {}), + }); +} + +function isProtocolError(value: unknown): value is AcpProtocolError { + return ( + typeof value === "object" && + value !== null && + "code" in value && + typeof value.code === "number" && + "message" in value && + typeof value.message === "string" + ); +} diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts new file mode 100644 index 00000000000..029ef4e67b0 --- /dev/null +++ b/packages/effect-acp/src/protocol.test.ts @@ -0,0 +1,147 @@ +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as Sink from "effect/Sink"; +import * as Stdio from "effect/Stdio"; + +import { it, assert } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import * as AcpProtocol from "./protocol"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function makeInMemoryStdio() { + return Effect.gen(function* () { + const input = yield* Queue.unbounded(); + const output = yield* Queue.unbounded(); + + return { + stdio: Stdio.make({ + stdin: Stream.fromQueue(input), + stdout: Sink.forEach((chunk: string | Uint8Array) => + Queue.offer(output, typeof chunk === "string" ? chunk : decoder.decode(chunk)), + ), + stderr: Sink.drain, + }), + input, + output, + }; + }); +} + +it.layer(NodeServices.layer)("effect-acp protocol", (it) => { + it.effect( + "emits exact JSON-RPC notifications and decodes inbound session/update and elicitation completion", + () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const notifications = + yield* Deferred.make>(); + yield* transport.notifications.incoming.pipe( + Stream.take(2), + Stream.runCollect, + Effect.flatMap((notificationChunk) => Deferred.succeed(notifications, notificationChunk)), + Effect.forkScoped, + ); + + yield* transport.notifications.sendSessionCancel({ sessionId: "session-1" }); + const outbound = yield* Queue.take(output); + assert.deepEqual(JSON.parse(outbound), { + jsonrpc: "2.0", + method: "session/cancel", + params: { + sessionId: "session-1", + }, + }); + + yield* Queue.offer( + input, + encoder.encode( + `${JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Inspect repository", + priority: "high", + status: "in_progress", + }, + ], + }, + }, + })}\n`, + ), + ); + + yield* Queue.offer( + input, + encoder.encode( + `${JSON.stringify({ + jsonrpc: "2.0", + method: "session/elicitation/complete", + params: { + elicitationId: "elicitation-1", + }, + })}\n`, + ), + ); + + const [update, completion] = yield* Deferred.await(notifications); + assert.equal(update?._tag, "SessionUpdate"); + assert.equal(completion?._tag, "ElicitationComplete"); + }), + ); + + it.effect("supports generic extension requests over the patched transport", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const response = yield* transport + .sendRequest("x/test", { hello: "world" }) + .pipe(Effect.forkScoped); + const outbound = yield* Queue.take(output); + assert.deepEqual(JSON.parse(outbound), { + jsonrpc: "2.0", + id: 1, + method: "x/test", + params: { + hello: "world", + }, + headers: [], + }); + + yield* Queue.offer( + input, + encoder.encode( + `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + ok: true, + }, + })}\n`, + ), + ); + + const resolved = yield* Fiber.join(response); + assert.deepEqual(resolved, { ok: true }); + }), + ); +}); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts new file mode 100644 index 00000000000..d103babb1e2 --- /dev/null +++ b/packages/effect-acp/src/protocol.ts @@ -0,0 +1,485 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as Stdio from "effect/Stdio"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; +import * as RpcMessage from "effect/unstable/rpc/RpcMessage"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as RpcServer from "effect/unstable/rpc/RpcServer"; + +import * as AcpSchema from "./_generated/schema.gen"; +import { CLIENT_METHODS } from "./_generated/meta.gen"; +import * as AcpError from "./errors"; + +export type AcpIncomingNotification = + | { + readonly _tag: "SessionUpdate"; + readonly method: typeof CLIENT_METHODS.session_update; + readonly params: typeof AcpSchema.SessionNotification.Type; + } + | { + readonly _tag: "SessionCancel"; + readonly method: "session/cancel"; + readonly params: typeof AcpSchema.CancelNotification.Type; + } + | { + readonly _tag: "ElicitationComplete"; + readonly method: typeof CLIENT_METHODS.session_elicitation_complete; + readonly params: typeof AcpSchema.ElicitationCompleteNotification.Type; + } + | { + readonly _tag: "ExtNotification"; + readonly method: string; + readonly params: unknown; + }; + +export interface AcpPatchedProtocolOptions { + readonly stdio: Stdio.Stdio; + readonly serverRequestMethods: ReadonlySet; + readonly onNotification?: ( + notification: AcpIncomingNotification, + ) => Effect.Effect; + readonly onExtRequest?: ( + method: string, + params: unknown, + ) => Effect.Effect; + readonly onProcessExit?: (error: AcpError.AcpProcessExitedError) => Effect.Effect; +} + +export interface AcpPatchedProtocol { + readonly clientProtocol: RpcClient.Protocol["Service"]; + readonly serverProtocol: RpcServer.Protocol["Service"]; + readonly notifications: { + readonly incoming: Stream.Stream; + readonly sendSessionCancel: ( + payload: typeof AcpSchema.CancelNotification.Type, + ) => Effect.Effect; + readonly sendExtNotification: ( + method: string, + payload: unknown, + ) => Effect.Effect; + }; + readonly sendRequest: ( + method: string, + payload: unknown, + ) => Effect.Effect; +} + +const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification); +const decodeSessionCancel = Schema.decodeUnknownEffect(AcpSchema.CancelNotification); +const decodeElicitationComplete = Schema.decodeUnknownEffect( + AcpSchema.ElicitationCompleteNotification, +); +const parserFactory = RpcSerialization.ndJsonRpc(); + +export const makeAcpPatchedProtocol = ( + options: AcpPatchedProtocolOptions, +): Effect.Effect => + Effect.gen(function* () { + const parser = parserFactory.makeUnsafe(); + const serverQueue = yield* Queue.unbounded(); + const clientQueue = yield* Queue.unbounded(); + const notificationQueue = yield* Queue.unbounded(); + const disconnects = yield* Queue.unbounded(); + const outgoing = yield* Queue.unbounded>(); + const nextRequestId = yield* Ref.make(1n); + const extPending = yield* Ref.make( + new Map>(), + ); + + const offerOutgoing = (message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded) => + Effect.try({ + try: () => parser.encode(message), + catch: (cause) => + new AcpError.AcpProtocolParseError({ + detail: "Failed to encode ACP message", + cause, + }), + }).pipe( + Effect.flatMap((encoded) => + encoded === undefined ? Effect.void : Queue.offer(outgoing, encoded).pipe(Effect.asVoid), + ), + ); + + const resolveExtPending = ( + requestId: string, + onFound: (deferred: Deferred.Deferred) => Effect.Effect, + ) => + Ref.modify(extPending, (pending) => { + const deferred = pending.get(requestId); + if (!deferred) { + return [Effect.void, pending] as const; + } + const next = new Map(pending); + next.delete(requestId); + return [onFound(deferred), next] as const; + }).pipe(Effect.flatten); + + const completeExtPendingFailure = (requestId: string, error: AcpError.AcpError) => + resolveExtPending(requestId, (deferred) => Deferred.fail(deferred, error)); + + const completeExtPendingSuccess = (requestId: string, value: unknown) => + resolveExtPending(requestId, (deferred) => Deferred.succeed(deferred, value)); + + const failAllExtPending = (error: AcpError.AcpError) => + Ref.get(extPending).pipe( + Effect.flatMap((pending) => + Effect.forEach([...pending.values()], (deferred) => Deferred.fail(deferred, error), { + discard: true, + }), + ), + Effect.andThen(Ref.set(extPending, new Map())), + ); + + const dispatchNotification = (notification: AcpIncomingNotification) => + Queue.offer(notificationQueue, notification).pipe( + Effect.andThen( + options.onNotification + ? options.onNotification(notification).pipe(Effect.catch(() => Effect.void)) + : Effect.void, + ), + Effect.asVoid, + ); + + const respondWithSuccess = (requestId: string, value: unknown) => + offerOutgoing({ + _tag: "Exit", + requestId, + exit: { + _tag: "Success", + value, + }, + }); + + const respondWithError = (requestId: string, error: AcpError.AcpRequestError) => + offerOutgoing({ + _tag: "Exit", + requestId, + exit: { + _tag: "Failure", + cause: [ + { + _tag: "Fail", + error: error.toProtocolError(), + }, + ], + }, + }); + + const handleExtRequest = (message: RpcMessage.RequestEncoded) => { + if (!options.onExtRequest) { + return respondWithError(message.id, AcpError.AcpRequestError.methodNotFound(message.tag)); + } + return options.onExtRequest(message.tag, message.payload).pipe( + Effect.matchEffect({ + onFailure: (error) => respondWithError(message.id, normalizeToRequestError(error)), + onSuccess: (value) => respondWithSuccess(message.id, value), + }), + ); + }; + + const handleRequestEncoded = (message: RpcMessage.RequestEncoded) => { + if (message.id === "") { + if (message.tag === CLIENT_METHODS.session_update) { + return decodeSessionUpdate(message.payload).pipe( + Effect.map( + (params) => + ({ + _tag: "SessionUpdate", + method: CLIENT_METHODS.session_update, + params, + }) satisfies AcpIncomingNotification, + ), + Effect.mapError( + (cause) => + new AcpError.AcpProtocolParseError({ + detail: `Invalid ${CLIENT_METHODS.session_update} notification payload`, + cause, + }), + ), + Effect.flatMap(dispatchNotification), + ); + } + if (message.tag === "session/cancel") { + return decodeSessionCancel(message.payload).pipe( + Effect.map( + (params) => + ({ + _tag: "SessionCancel", + method: "session/cancel", + params, + }) satisfies AcpIncomingNotification, + ), + Effect.mapError( + (cause) => + new AcpError.AcpProtocolParseError({ + detail: "Invalid session/cancel notification payload", + cause, + }), + ), + Effect.flatMap(dispatchNotification), + ); + } + if (message.tag === CLIENT_METHODS.session_elicitation_complete) { + return decodeElicitationComplete(message.payload).pipe( + Effect.map( + (params) => + ({ + _tag: "ElicitationComplete", + method: CLIENT_METHODS.session_elicitation_complete, + params, + }) satisfies AcpIncomingNotification, + ), + Effect.mapError( + (cause) => + new AcpError.AcpProtocolParseError({ + detail: `Invalid ${CLIENT_METHODS.session_elicitation_complete} notification payload`, + cause, + }), + ), + Effect.flatMap(dispatchNotification), + ); + } + return dispatchNotification({ + _tag: "ExtNotification", + method: message.tag, + params: message.payload, + }); + } + + if (!options.serverRequestMethods.has(message.tag)) { + return handleExtRequest(message).pipe( + Effect.catch(() => + respondWithError(message.id, AcpError.AcpRequestError.internalError()), + ), + Effect.asVoid, + ); + } + + return Queue.offer(serverQueue, message).pipe(Effect.asVoid); + }; + + const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => + Ref.get(extPending).pipe( + Effect.flatMap((pending) => { + if (!pending.has(message.requestId)) { + return Queue.offer(clientQueue, message).pipe(Effect.asVoid); + } + if (message.exit._tag === "Success") { + return completeExtPendingSuccess(message.requestId, message.exit.value); + } + const failure = message.exit.cause.find((entry) => entry._tag === "Fail"); + if (failure && isProtocolError(failure.error)) { + return completeExtPendingFailure( + message.requestId, + AcpError.AcpRequestError.fromProtocolError(failure.error), + ); + } + return completeExtPendingFailure( + message.requestId, + AcpError.AcpRequestError.internalError("Extension request failed"), + ); + }), + ); + + const routeDecodedMessage = ( + message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, + ): Effect.Effect => { + switch (message._tag) { + case "Request": + return handleRequestEncoded(message); + case "Exit": + return handleExitEncoded(message); + case "Chunk": + return Ref.get(extPending).pipe( + Effect.flatMap((pending) => + pending.has(message.requestId) + ? completeExtPendingFailure( + message.requestId, + AcpError.AcpRequestError.internalError( + "Streaming extension responses are not supported", + ), + ) + : Queue.offer(clientQueue, message).pipe(Effect.asVoid), + ), + ); + case "Defect": + case "ClientProtocolError": + case "Pong": + return Queue.offer(clientQueue, message).pipe(Effect.asVoid); + case "Ack": + case "Interrupt": + case "Ping": + case "Eof": + return Queue.offer(serverQueue, message).pipe(Effect.asVoid); + } + }; + + yield* options.stdio.stdin.pipe( + Stream.runForEach((data) => + Effect.try({ + try: () => + parser.decode(data) as ReadonlyArray< + RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded + >, + catch: (cause) => + new AcpError.AcpProtocolParseError({ + detail: "Failed to decode ACP wire message", + cause, + }), + }).pipe( + Effect.flatMap((messages) => + Effect.forEach(messages, routeDecodedMessage, { + discard: true, + }), + ), + ), + ), + Effect.catch((error) => { + const normalized = AcpError.normalizeAcpError(error); + const rpcClientError = new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: normalized.message, + cause: normalized, + }), + }); + return Queue.offer(clientQueue, { + _tag: "ClientProtocolError", + error: rpcClientError, + }).pipe(Effect.asVoid); + }), + Effect.ensuring( + Effect.gen(function* () { + const error = new AcpError.AcpProcessExitedError({}); + yield* Queue.offer(disconnects, 0); + yield* failAllExtPending(error); + yield* Queue.offer(clientQueue, { + _tag: "ClientProtocolError", + error: new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + }); + if (options.onProcessExit) { + yield* options.onProcessExit(error); + } + }), + ), + Effect.forkScoped, + ); + + yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout), Effect.forkScoped); + + const clientProtocol = RpcClient.Protocol.of({ + run: (f) => + Stream.fromQueue(clientQueue).pipe( + Stream.runForEach((message) => f(message)), + Effect.forever, + ), + send: (request) => offerOutgoing(request).pipe(Effect.mapError(toRpcClientError)), + supportsAck: true, + supportsTransferables: false, + }); + + const serverProtocol = RpcServer.Protocol.of({ + run: (f) => + Stream.fromQueue(serverQueue).pipe( + Stream.runForEach((message) => f(0, message)), + Effect.forever, + ), + disconnects, + send: (_clientId, response) => offerOutgoing(response).pipe(Effect.orDie), + end: () => Queue.end(outgoing).pipe(Effect.orDie), + clientIds: Effect.succeed(new Set([0])), + initialMessage: Effect.succeedNone, + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: true, + }); + + const sendNotification = (method: string, payload: unknown) => + Queue.offer( + outgoing, + `${JSON.stringify({ + jsonrpc: "2.0", + method, + ...(payload !== undefined ? { params: payload } : {}), + })}\n`, + ).pipe(Effect.asVoid, Effect.mapError(AcpError.normalizeAcpError)); + + const sendRequest = (method: string, payload: unknown) => + Effect.gen(function* () { + const requestId = yield* Ref.modify( + nextRequestId, + (current) => [current, current + 1n] as const, + ); + const deferred = yield* Deferred.make(); + yield* Ref.update(extPending, (pending) => + new Map(pending).set(String(requestId), deferred), + ); + yield* offerOutgoing({ + _tag: "Request", + id: String(requestId), + tag: method, + payload, + headers: [], + }).pipe( + Effect.catch((error) => + Ref.update(extPending, (pending) => { + const next = new Map(pending); + next.delete(String(requestId)); + return next; + }).pipe(Effect.andThen(Effect.fail(error))), + ), + ); + return yield* Deferred.await(deferred); + }); + + return { + clientProtocol, + serverProtocol, + notifications: { + incoming: Stream.fromQueue(notificationQueue), + sendSessionCancel: (payload) => sendNotification("session/cancel", payload), + sendExtNotification: sendNotification, + }, + sendRequest, + } satisfies AcpPatchedProtocol; + }); + +function isProtocolError( + value: unknown, +): value is { code: number; message: string; data?: unknown } { + return ( + typeof value === "object" && + value !== null && + "code" in value && + typeof value.code === "number" && + "message" in value && + typeof value.message === "string" + ); +} + +function normalizeToRequestError(error: unknown): AcpError.AcpRequestError { + const normalized = AcpError.normalizeAcpError(error); + return Schema.is(AcpError.AcpRequestError)(normalized) + ? normalized + : AcpError.AcpRequestError.internalError(normalized.message); +} + +function toRpcClientError(error: AcpError.AcpError): RpcClientError.RpcClientError { + return new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }); +} diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts new file mode 100644 index 00000000000..c3faa3847f2 --- /dev/null +++ b/packages/effect-acp/src/rpc.ts @@ -0,0 +1,159 @@ +import * as Schema from "effect/Schema"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; + +import * as AcpSchema from "./_generated/schema.gen"; +import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen"; + +function makeAcpRpc< + const Tag extends string, + Payload extends Schema.Top | Schema.Struct.Fields, + Success extends Schema.Top, +>(tag: Tag, options: { readonly payload: Payload; readonly success: Success }) { + return Rpc.make(tag, { + payload: options.payload, + success: options.success, + error: AcpSchema.Error, + }); +} + +export const InitializeRpc = makeAcpRpc(AGENT_METHODS.initialize, { + payload: AcpSchema.InitializeRequest, + success: AcpSchema.InitializeResponse, +}); + +export const AuthenticateRpc = makeAcpRpc(AGENT_METHODS.authenticate, { + payload: AcpSchema.AuthenticateRequest, + success: AcpSchema.AuthenticateResponse, +}); + +export const LogoutRpc = makeAcpRpc(AGENT_METHODS.logout, { + payload: AcpSchema.LogoutRequest, + success: AcpSchema.LogoutResponse, +}); + +export const NewSessionRpc = makeAcpRpc(AGENT_METHODS.session_new, { + payload: AcpSchema.NewSessionRequest, + success: AcpSchema.NewSessionResponse, +}); + +export const LoadSessionRpc = makeAcpRpc(AGENT_METHODS.session_load, { + payload: AcpSchema.LoadSessionRequest, + success: AcpSchema.LoadSessionResponse, +}); + +export const ListSessionsRpc = makeAcpRpc(AGENT_METHODS.session_list, { + payload: AcpSchema.ListSessionsRequest, + success: AcpSchema.ListSessionsResponse, +}); + +export const ForkSessionRpc = makeAcpRpc(AGENT_METHODS.session_fork, { + payload: AcpSchema.ForkSessionRequest, + success: AcpSchema.ForkSessionResponse, +}); + +export const ResumeSessionRpc = makeAcpRpc(AGENT_METHODS.session_resume, { + payload: AcpSchema.ResumeSessionRequest, + success: AcpSchema.ResumeSessionResponse, +}); + +export const CloseSessionRpc = makeAcpRpc(AGENT_METHODS.session_close, { + payload: AcpSchema.CloseSessionRequest, + success: AcpSchema.CloseSessionResponse, +}); + +export const SetSessionModeRpc = makeAcpRpc(AGENT_METHODS.session_set_mode, { + payload: AcpSchema.SetSessionModeRequest, + success: AcpSchema.SetSessionModeResponse, +}); + +export const PromptRpc = makeAcpRpc(AGENT_METHODS.session_prompt, { + payload: AcpSchema.PromptRequest, + success: AcpSchema.PromptResponse, +}); + +export const SetSessionModelRpc = makeAcpRpc(AGENT_METHODS.session_set_model, { + payload: AcpSchema.SetSessionModelRequest, + success: AcpSchema.SetSessionModelResponse, +}); + +export const SetSessionConfigOptionRpc = makeAcpRpc(AGENT_METHODS.session_set_config_option, { + payload: AcpSchema.SetSessionConfigOptionRequest, + success: AcpSchema.SetSessionConfigOptionResponse, +}); + +export const ReadTextFileRpc = makeAcpRpc(CLIENT_METHODS.fs_read_text_file, { + payload: AcpSchema.ReadTextFileRequest, + success: AcpSchema.ReadTextFileResponse, +}); + +export const WriteTextFileRpc = makeAcpRpc(CLIENT_METHODS.fs_write_text_file, { + payload: AcpSchema.WriteTextFileRequest, + success: AcpSchema.WriteTextFileResponse, +}); + +export const RequestPermissionRpc = makeAcpRpc(CLIENT_METHODS.session_request_permission, { + payload: AcpSchema.RequestPermissionRequest, + success: AcpSchema.RequestPermissionResponse, +}); + +export const ElicitationRpc = makeAcpRpc(CLIENT_METHODS.session_elicitation, { + payload: AcpSchema.ElicitationRequest, + success: AcpSchema.ElicitationResponse, +}); + +export const CreateTerminalRpc = makeAcpRpc(CLIENT_METHODS.terminal_create, { + payload: AcpSchema.CreateTerminalRequest, + success: AcpSchema.CreateTerminalResponse, +}); + +export const TerminalOutputRpc = makeAcpRpc(CLIENT_METHODS.terminal_output, { + payload: AcpSchema.TerminalOutputRequest, + success: AcpSchema.TerminalOutputResponse, +}); + +export const ReleaseTerminalRpc = makeAcpRpc(CLIENT_METHODS.terminal_release, { + payload: AcpSchema.ReleaseTerminalRequest, + success: AcpSchema.ReleaseTerminalResponse, +}); + +export const WaitForTerminalExitRpc = makeAcpRpc(CLIENT_METHODS.terminal_wait_for_exit, { + payload: AcpSchema.WaitForTerminalExitRequest, + success: AcpSchema.WaitForTerminalExitResponse, +}); + +export const KillTerminalRpc = makeAcpRpc(CLIENT_METHODS.terminal_kill, { + payload: AcpSchema.KillTerminalRequest, + success: AcpSchema.KillTerminalResponse, +}); + +export const AgentRpcs = RpcGroup.make( + InitializeRpc, + AuthenticateRpc, + LogoutRpc, + NewSessionRpc, + LoadSessionRpc, + ListSessionsRpc, + ForkSessionRpc, + ResumeSessionRpc, + CloseSessionRpc, + SetSessionModeRpc, + PromptRpc, + SetSessionModelRpc, + SetSessionConfigOptionRpc, +); + +export const ClientRpcs = RpcGroup.make( + ReadTextFileRpc, + WriteTextFileRpc, + RequestPermissionRpc, + ElicitationRpc, + CreateTerminalRpc, + TerminalOutputRpc, + ReleaseTerminalRpc, + WaitForTerminalExitRpc, + KillTerminalRpc, +); + +export const ClientRequestMethodSet = new Set(ClientRpcs.requests.keys()); +export const AgentRequestMethodSet = new Set(AgentRpcs.requests.keys()); diff --git a/packages/effect-acp/src/server.ts b/packages/effect-acp/src/server.ts new file mode 100644 index 00000000000..36e9d394215 --- /dev/null +++ b/packages/effect-acp/src/server.ts @@ -0,0 +1,135 @@ +import * as Effect from "effect/Effect"; + +import * as AcpSchema from "./_generated/schema.gen"; +import { CLIENT_METHODS } from "./_generated/meta.gen"; +import type * as AcpError from "./errors"; +import type * as AcpProtocol from "./protocol"; +import * as AcpTerminal from "./terminal"; + +export interface AcpServerConnection { + /** + * Sends a `session/update` notification to the client. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly sessionUpdate: ( + payload: AcpSchema.SessionNotification, + ) => Effect.Effect; + /** + * Requests client permission for an operation. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission + */ + readonly requestPermission: ( + payload: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect; + /** + * Requests structured user input from the client. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation + */ + readonly elicit: ( + payload: AcpSchema.ElicitationRequest, + ) => Effect.Effect; + /** + * Requests file contents from the client. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file + */ + readonly readTextFile: ( + payload: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect; + /** + * Writes a text file through the client. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + */ + readonly writeTextFile: ( + payload: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect; + /** + * Creates a terminal on the client side. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create + */ + readonly createTerminal: ( + payload: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect; + /** + * Sends an ACP extension request. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extRequest: ( + method: string, + payload: unknown, + ) => Effect.Effect; + /** + * Sends a `session/elicitation/complete` notification to the client. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete + */ + readonly elicitationComplete: ( + payload: AcpSchema.ElicitationCompleteNotification, + ) => Effect.Effect; + /** + * Sends an ACP extension notification. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extNotification: ( + method: string, + payload: unknown, + ) => Effect.Effect; +} + +export const makeAcpServerConnection = ( + transport: AcpProtocol.AcpPatchedProtocol, +): AcpServerConnection => { + const request = (method: string, payload: unknown) => + transport.sendRequest(method, payload).pipe(Effect.map((value) => value as A)); + + return { + sessionUpdate: (payload) => + transport.notifications.sendExtNotification(CLIENT_METHODS.session_update, payload), + requestPermission: (payload) => + request( + CLIENT_METHODS.session_request_permission, + payload, + ), + elicit: (payload) => + request(CLIENT_METHODS.session_elicitation, payload), + readTextFile: (payload) => + request(CLIENT_METHODS.fs_read_text_file, payload), + writeTextFile: (payload) => + request(CLIENT_METHODS.fs_write_text_file, payload).pipe( + Effect.map((response) => response ?? {}), + ), + createTerminal: (payload) => + request(CLIENT_METHODS.terminal_create, payload).pipe( + Effect.map((response) => + AcpTerminal.makeTerminal({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + output: request(CLIENT_METHODS.terminal_output, { + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + waitForExit: request( + CLIENT_METHODS.terminal_wait_for_exit, + { + sessionId: payload.sessionId, + terminalId: response.terminalId, + }, + ), + kill: request(CLIENT_METHODS.terminal_kill, { + sessionId: payload.sessionId, + terminalId: response.terminalId, + }).pipe(Effect.map((result) => result ?? {})), + release: request(CLIENT_METHODS.terminal_release, { + sessionId: payload.sessionId, + terminalId: response.terminalId, + }).pipe(Effect.map((result) => result ?? {})), + }), + ), + ), + extRequest: transport.sendRequest, + elicitationComplete: (payload) => + transport.notifications.sendExtNotification( + CLIENT_METHODS.session_elicitation_complete, + payload, + ), + extNotification: transport.notifications.sendExtNotification, + }; +}; diff --git a/packages/effect-acp/src/terminal.ts b/packages/effect-acp/src/terminal.ts new file mode 100644 index 00000000000..6fad3d08252 --- /dev/null +++ b/packages/effect-acp/src/terminal.ts @@ -0,0 +1,49 @@ +import * as Effect from "effect/Effect"; + +import type * as AcpSchema from "./_generated/schema.gen"; +import type * as AcpError from "./errors"; + +export interface AcpTerminal { + readonly sessionId: string; + readonly terminalId: string; + /** Reads buffered output from the terminal. + * Spec: https://agentclientprotocol.com/protocol/schema#terminal/output + */ + readonly output: Effect.Effect; + /** Waits for terminal exit and returns the exit result. + * Spec: https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit + */ + readonly waitForExit: Effect.Effect; + /** Terminates the terminal process. + * Spec: https://agentclientprotocol.com/protocol/schema#terminal/kill + */ + readonly kill: Effect.Effect; + /** Releases the terminal handle from the ACP session. + * Spec: https://agentclientprotocol.com/protocol/schema#terminal/release + */ + readonly release: Effect.Effect; +} + +export interface MakeTerminalOptions { + readonly sessionId: string; + readonly terminalId: string; + readonly output: Effect.Effect; + readonly waitForExit: Effect.Effect; + readonly kill: Effect.Effect; + readonly release: Effect.Effect; +} + +export function makeTerminal(options: MakeTerminalOptions): AcpTerminal { + return { + sessionId: options.sessionId, + terminalId: options.terminalId, + output: options.output, + waitForExit: options.waitForExit, + kill: options.kill, + release: options.release, + }; +} + +export const TerminalHandle = { + make: makeTerminal, +}; diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts new file mode 100644 index 00000000000..0fff9c1f6f4 --- /dev/null +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -0,0 +1,55 @@ +import * as Effect from "effect/Effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; + +import * as AcpClient from "../../src/client"; + +Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make("cursor-agent", ["acp"], { + cwd: process.cwd(), + shell: process.platform === "win32", + }); + const handle = yield* spawner.spawn(command); + const client = yield* AcpClient.fromChildProcess(handle, { + handlers: { + requestPermission: () => + Effect.succeed({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }), + sessionUpdate: (notification) => Effect.logInfo("session/update", notification), + }, + }); + + const initialized = yield* client.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: "effect-acp-example", + version: "0.0.0", + }, + }); + yield* Effect.logInfo("initialized", initialized); + + const session = yield* client.createSession({ + cwd: process.cwd(), + mcpServers: [], + }); + yield* Effect.logInfo("created session", { sessionId: session.sessionId }); + + const result = yield* client.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Summarize this repository." }], + }); + + yield* Effect.logInfo("prompt result", result); + yield* client.cancel({ sessionId: session.sessionId }); +}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); diff --git a/packages/effect-acp/test/fixtures/acp-mock-peer.ts b/packages/effect-acp/test/fixtures/acp-mock-peer.ts new file mode 100644 index 00000000000..593ba1820d3 --- /dev/null +++ b/packages/effect-acp/test/fixtures/acp-mock-peer.ts @@ -0,0 +1,222 @@ +import { createInterface } from "node:readline"; + +const rl = createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +let nextRequestId = 1000; +const pending = new Map< + number | string, + { resolve: (value: unknown) => void; reject: (error: unknown) => void } +>(); + +function writeMessage(message: unknown) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function respond(id: number | string | null | undefined, result: unknown) { + writeMessage({ + jsonrpc: "2.0", + id, + result, + }); +} + +function respondError( + id: number | string | null | undefined, + code: number, + message: string, + data?: unknown, +) { + writeMessage({ + jsonrpc: "2.0", + id, + error: { + code, + message, + ...(data !== undefined ? { data } : {}), + }, + }); +} + +function notify(method: string, params?: unknown) { + writeMessage({ + jsonrpc: "2.0", + method, + ...(params !== undefined ? { params } : {}), + }); +} + +function requestClient(method: string, params?: unknown) { + const id = nextRequestId++; + writeMessage({ + jsonrpc: "2.0", + id, + method, + ...(params !== undefined ? { params } : {}), + }); + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + }); +} + +async function handleRequest(message: { + readonly id: number | string | null; + readonly method: string; + readonly params?: unknown; +}) { + switch (message.method) { + case "initialize": + respond(message.id, { + protocolVersion: 1, + agentCapabilities: { + sessionCapabilities: { + list: {}, + }, + }, + agentInfo: { + name: "mock-agent", + version: "0.0.0", + }, + }); + return; + case "authenticate": + respond(message.id, {}); + return; + case "logout": + respond(message.id, {}); + return; + case "session/new": + respond(message.id, { + sessionId: "mock-session-1", + }); + return; + case "session/load": + respond(message.id, {}); + return; + case "session/list": + respond(message.id, { + sessions: [ + { + sessionId: "mock-session-1", + cwd: process.cwd(), + }, + ], + }); + return; + case "session/prompt": { + await requestClient("session/request_permission", { + sessionId: "mock-session-1", + options: [ + { + optionId: "allow", + name: "Allow", + kind: "allow_once", + }, + ], + toolCall: { + toolCallId: "tool-1", + title: "Read project files", + }, + }); + + await requestClient("session/elicitation", { + mode: "form", + requestedSchema: { + type: "object", + title: "Need confirmation", + properties: { + approved: { + type: "boolean", + title: "Approved", + }, + }, + required: ["approved"], + }, + }); + + notify("session/update", { + sessionId: "mock-session-1", + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Inspect the repository", + priority: "high", + status: "in_progress", + }, + ], + }, + }); + + notify("session/elicitation/complete", { + elicitationId: "elicitation-1", + }); + + respond(message.id, { + stopReason: "end_turn", + }); + return; + } + default: + respond(message.id, { + echoedMethod: message.method, + echoedParams: message.params ?? null, + }); + return; + } +} + +function handleResponse(message: { + readonly id: number | string | null; + readonly result?: unknown; + readonly error?: { readonly code: number; readonly message: string; readonly data?: unknown }; +}) { + const pendingRequest = pending.get(message.id ?? ""); + if (!pendingRequest) { + return; + } + pending.delete(message.id ?? ""); + if (message.error) { + pendingRequest.reject(message.error); + } else { + pendingRequest.resolve(message.result); + } +} + +rl.on("line", (line) => { + const trimmed = line.trim(); + if (trimmed.length === 0) { + return; + } + + const message = JSON.parse(trimmed) as + | { readonly id: number | string | null; readonly method: string; readonly params?: unknown } + | { + readonly id: number | string | null; + readonly result?: unknown; + readonly error?: { + readonly code: number; + readonly message: string; + readonly data?: unknown; + }; + } + | { readonly method: string; readonly params?: unknown }; + + if ("method" in message && "id" in message) { + void handleRequest(message).catch((error) => { + respondError(message.id, -32603, error instanceof Error ? error.message : String(error)); + }); + return; + } + + if ("id" in message && ("result" in message || "error" in message)) { + handleResponse(message); + return; + } + + if ("method" in message && !("id" in message)) { + return; + } +}); diff --git a/packages/effect-acp/tsconfig.json b/packages/effect-acp/tsconfig.json new file mode 100644 index 00000000000..61162f9454a --- /dev/null +++ b/packages/effect-acp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node"], + "diagnosticSeverity": { + "importFromBarrel": "error", + "anyUnknownInErrorContext": "warning", + "instanceOfSchema": "warning", + "deterministicKeys": "warning" + } + } + ] + }, + "include": ["src", "scripts", "test"] +} From 84f9533f28ec760662d8c3937f2c1dae46573c91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 12:51:04 -0700 Subject: [PATCH 22/82] improve sdk and use it --- apps/server/package.json | 1 + .../{acp-mock-agent.mjs => acp-mock-agent.ts} | 64 +- .../src/persistence/NodeSqliteClient.ts | 27 +- .../src/provider/Layers/CursorAdapter.test.ts | 18 +- .../src/provider/Layers/CursorAdapter.ts | 1408 ++++------------- .../provider/Layers/ProviderRegistry.test.ts | 7 + .../provider/acp/AcpAdapterSupport.test.ts | 107 ++ .../src/provider/acp/AcpAdapterSupport.ts | 239 +++ apps/server/src/provider/acp/AcpErrors.ts | 51 - .../provider/acp/AcpJsonRpcConnection.test.ts | 50 +- .../src/provider/acp/AcpJsonRpcConnection.ts | 256 --- .../src/provider/acp/AcpRuntimeModel.test.ts | 266 ++++ .../src/provider/acp/AcpRuntimeModel.ts | 445 ++++++ .../src/provider/acp/AcpSessionRuntime.ts | 265 ++++ apps/server/src/provider/acp/AcpTypes.ts | 147 -- .../provider/acp/CursorAcpCliProbe.test.ts | 109 +- .../provider/acp/CursorAcpExtension.test.ts | 73 + .../src/provider/acp/CursorAcpExtension.ts | 116 ++ apps/server/src/serverLayers.ts | 2 +- bun.lock | 40 +- package.json | 13 +- packages/contracts/src/providerRuntime.ts | 18 +- packages/effect-acp/package.json | 74 +- .../effect-acp/src/_generated/schema.gen.ts | 554 +++++-- packages/effect-acp/src/child-process.ts | 11 +- packages/effect-acp/src/client.test.ts | 23 + packages/effect-acp/src/client.ts | 98 +- packages/effect-acp/src/protocol.test.ts | 10 +- packages/effect-acp/src/protocol.ts | 10 +- packages/effect-acp/src/schema.ts | 2 + .../examples/cursor-acp-client.example.ts | 7 +- .../effect-acp/test/fixtures/acp-mock-peer.ts | 10 + patches/effect@4.0.0-beta.41.patch | 108 ++ 33 files changed, 2693 insertions(+), 1936 deletions(-) rename apps/server/scripts/{acp-mock-agent.mjs => acp-mock-agent.ts} (80%) create mode 100644 apps/server/src/provider/acp/AcpAdapterSupport.test.ts create mode 100644 apps/server/src/provider/acp/AcpAdapterSupport.ts delete mode 100644 apps/server/src/provider/acp/AcpErrors.ts delete mode 100644 apps/server/src/provider/acp/AcpJsonRpcConnection.ts create mode 100644 apps/server/src/provider/acp/AcpRuntimeModel.test.ts create mode 100644 apps/server/src/provider/acp/AcpRuntimeModel.ts create mode 100644 apps/server/src/provider/acp/AcpSessionRuntime.ts delete mode 100644 apps/server/src/provider/acp/AcpTypes.ts create mode 100644 apps/server/src/provider/acp/CursorAcpExtension.test.ts create mode 100644 apps/server/src/provider/acp/CursorAcpExtension.ts create mode 100644 packages/effect-acp/src/schema.ts create mode 100644 patches/effect@4.0.0-beta.41.patch diff --git a/apps/server/package.json b/apps/server/package.json index ea818b7d3e1..7e695dcc092 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", + "effect-acp": "workspace:*", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0" diff --git a/apps/server/scripts/acp-mock-agent.mjs b/apps/server/scripts/acp-mock-agent.ts similarity index 80% rename from apps/server/scripts/acp-mock-agent.mjs rename to apps/server/scripts/acp-mock-agent.ts index c828cc453d6..9ab7c9783f3 100644 --- a/apps/server/scripts/acp-mock-agent.mjs +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * Minimal NDJSON JSON-RPC "agent" for ACP client tests. * Reads stdin lines; writes responses/notifications to stdout. @@ -6,6 +6,8 @@ import * as readline from "node:readline"; import { appendFileSync } from "node:fs"; +import { AGENT_METHODS, CLIENT_METHODS } from "effect-acp/schema"; + const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; @@ -51,7 +53,7 @@ const availableModes = [ ]; const pendingPermissionRequests = new Map(); -function send(obj) { +function send(obj: unknown) { process.stdout.write(`${JSON.stringify(obj)}\n`); } @@ -62,10 +64,10 @@ function modeState() { }; } -function sendSessionUpdate(update, session = sessionId) { +function sendSessionUpdate(update: unknown, session = sessionId) { send({ jsonrpc: "2.0", - method: "session/update", + method: CLIENT_METHODS.session_update, params: { sessionId: session, update, @@ -87,8 +89,13 @@ rl.on("line", (line) => { appendFileSync(requestLogPath, `${JSON.stringify(msg)}\n`, "utf8"); } - const id = msg.id; - const method = msg.method; + const rpcMessage = msg as { + id?: number | string; + method?: string; + params?: Record; + }; + const id = rpcMessage.id; + const method = rpcMessage.method; if (method === undefined && id !== undefined && pendingPermissionRequests.has(id)) { const pending = pendingPermissionRequests.get(id); @@ -123,7 +130,7 @@ rl.on("line", (line) => { return; } - if (method === "initialize" && id !== undefined) { + if (method === AGENT_METHODS.initialize && id !== undefined) { send({ jsonrpc: "2.0", id, @@ -135,12 +142,12 @@ rl.on("line", (line) => { return; } - if (method === "authenticate" && id !== undefined) { + if (method === AGENT_METHODS.authenticate && id !== undefined) { send({ jsonrpc: "2.0", id, result: { authenticated: true } }); return; } - if (method === "session/new" && id !== undefined) { + if (method === AGENT_METHODS.session_new && id !== undefined) { send({ jsonrpc: "2.0", id, @@ -153,14 +160,14 @@ rl.on("line", (line) => { return; } - if (method === "session/load" && id !== undefined) { - const requestedSessionId = msg.params?.sessionId ?? sessionId; + if (method === AGENT_METHODS.session_load && id !== undefined) { + const requestedSessionId = rpcMessage.params?.sessionId ?? sessionId; sendSessionUpdate( { sessionUpdate: "user_message_chunk", content: { type: "text", text: "replay" }, }, - requestedSessionId, + String(requestedSessionId), ); send({ jsonrpc: "2.0", @@ -173,9 +180,9 @@ rl.on("line", (line) => { return; } - if (method === "session/set_config_option" && id !== undefined) { - const configId = msg.params?.configId; - const value = msg.params?.value; + if (method === AGENT_METHODS.session_set_config_option && id !== undefined) { + const configId = rpcMessage.params?.configId; + const value = rpcMessage.params?.value; if (configId === "model" && typeof value === "string") { currentModelId = value; } @@ -187,8 +194,8 @@ rl.on("line", (line) => { return; } - if (method === "session/prompt" && id !== undefined) { - const requestedSessionId = msg.params?.sessionId ?? sessionId; + if (method === AGENT_METHODS.session_prompt && id !== undefined) { + const requestedSessionId = String(rpcMessage.params?.sessionId ?? sessionId); if (emitToolCalls) { const toolCallId = "tool-call-1"; const permissionRequestId = nextRequestId++; @@ -221,7 +228,7 @@ rl.on("line", (line) => { send({ jsonrpc: "2.0", id: permissionRequestId, - method: "session/request_permission", + method: CLIENT_METHODS.session_request_permission, params: { sessionId: requestedSessionId, toolCall: { @@ -282,12 +289,15 @@ rl.on("line", (line) => { return; } - if ((method === "session/set_mode" || method === "session/mode/set") && id !== undefined) { + if ( + (method === AGENT_METHODS.session_set_mode || method === "session/mode/set") && + id !== undefined + ) { const nextModeId = - typeof msg.params?.modeId === "string" - ? msg.params.modeId - : typeof msg.params?.mode === "string" - ? msg.params.mode + typeof rpcMessage.params?.modeId === "string" + ? rpcMessage.params.modeId + : typeof rpcMessage.params?.mode === "string" + ? rpcMessage.params.mode : undefined; if (typeof nextModeId === "string" && nextModeId.trim()) { currentModeId = nextModeId.trim(); @@ -296,12 +306,14 @@ rl.on("line", (line) => { currentModeId, }); } - send({ jsonrpc: "2.0", id, result: null }); + send({ jsonrpc: "2.0", id, result: {} }); return; } - if (method === "session/cancel" && id !== undefined) { - send({ jsonrpc: "2.0", id, result: null }); + if (method === AGENT_METHODS.session_cancel) { + if (id !== undefined) { + send({ jsonrpc: "2.0", id, result: null }); + } return; } diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 1d6e22d9b0e..de03a1dc755 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -20,7 +20,7 @@ import * as Stream from "effect/Stream"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import * as Client from "effect/unstable/sql/SqlClient"; import type { Connection } from "effect/unstable/sql/SqlConnection"; -import { SqlError } from "effect/unstable/sql/SqlError"; +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"; import * as Statement from "effect/unstable/sql/Statement"; const ATTR_DB_SYSTEM_NAME = "db.system.name"; @@ -109,7 +109,13 @@ const makeWithDatabase = ( lookup: (sql: string) => Effect.try({ try: () => db.prepare(sql), - catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to prepare statement", + operation: "prepare", + }), + }), }), }); @@ -127,7 +133,14 @@ const makeWithDatabase = ( const result = statement.run(...(params as any)); return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); } catch (cause) { - return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" })); + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to execute statement", + operation: "execute", + }), + }), + ); } }); @@ -150,7 +163,13 @@ const makeWithDatabase = ( statement.run(...(params as any)); return []; }, - catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to execute statement", + operation: "execute", + }), + }), }), (statement) => Effect.sync(() => { diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index a0af6596ec4..94e1b26274e 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -16,7 +16,8 @@ import { makeCursorAdapterLive } from "./CursorAdapter.ts"; import { resolveCursorAcpModelId } from "./CursorProvider.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const bunExe = "bun"; async function makeMockAgentWrapper(extraEnv?: Record) { const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-mock-")); @@ -26,7 +27,7 @@ async function makeMockAgentWrapper(extraEnv?: Record) { .join("\n"); const script = `#!/bin/sh ${envExports} -exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" +exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@" `; await writeFile(wrapperPath, script, "utf8"); await chmod(wrapperPath, 0o755); @@ -48,7 +49,7 @@ printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)} printf '\n' >> ${JSON.stringify(argvLogPath)} export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} ${envExports} -exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" +exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@" `; await writeFile(wrapperPath, script, "utf8"); await chmod(wrapperPath, 0o755); @@ -140,13 +141,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated"); assert.isDefined(planUpdate); if (planUpdate?.type === "turn.plan.updated") { - assert.deepStrictEqual(planUpdate.payload, { - explanation: "Mock plan while in code", - plan: [ - { step: "Inspect mock ACP state", status: "completed" }, - { step: "Implement the requested change", status: "inProgress" }, - ], - }); + assert.deepStrictEqual(planUpdate.payload.plan, [ + { step: "Inspect mock ACP state", status: "completed" }, + { step: "Implement the requested change", status: "inProgress" }, + ]); } yield* adapter.stopSession(threadId); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 804a806a2e9..0fc47002ee0 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1,33 +1,27 @@ /** - * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP JSON-RPC. + * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP. * * @module CursorAdapterLive */ import * as nodePath from "node:path"; -import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { ApprovalRequestId, EventId, - type ProviderInteractionMode, type ProviderApprovalDecision, + type ProviderInteractionMode, type ProviderRuntimeEvent, type ProviderSession, type ProviderUserInputAnswers, - RuntimeItemId, RuntimeRequestId, type RuntimeMode, type ThreadId, - type ToolLifecycleItemType, TurnId, - type UserInputQuestion, } from "@t3tools/contracts"; import { - Cause, DateTime, Deferred, Effect, - Exit, Fiber, FileSystem, Layer, @@ -36,6 +30,10 @@ import { Schema, Stream, } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { defineExtRequest } from "effect-acp/client"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -43,25 +41,37 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, - type ProviderAdapterError, } from "../Errors.ts"; +import { makeAcpSessionRuntime, type AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; +import { + acpPermissionOutcome, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, + mapAcpToAdapterError, +} from "../acp/AcpAdapterSupport.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; import { - attachAcpJsonRpcConnection, - disposeAcpChild, - spawnAcpChildProcess, - type AcpJsonRpcConnection, -} from "../acp/AcpJsonRpcConnection.ts"; -import type { AcpInboundMessage } from "../acp/AcpTypes.ts"; -import { AcpProcessExitedError, AcpRpcError, type AcpError } from "../acp/AcpErrors.ts"; + CursorAskQuestionRequest, + CursorCreatePlanRequest, + CursorUpdateTodosRequest, + extractAskQuestions, + extractPlanMarkdown, + extractTodosAsPlan, +} from "../acp/CursorAcpExtension.ts"; import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { resolveCursorAcpModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "cursor" as const; - const CURSOR_RESUME_VERSION = 1 as const; const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; @@ -72,6 +82,32 @@ export interface CursorAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; } +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly requestType: + | "exec_command_approval" + | "file_read_approval" + | "file_change_approval" + | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface CursorSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly acp: AcpSessionRuntime; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + stopped: boolean; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -83,18 +119,6 @@ function parseCursorResume(raw: unknown): { sessionId: string } | undefined { return { sessionId: raw.sessionId.trim() }; } -function extractModelConfigId(sessionResponse: unknown): string | undefined { - if (!isRecord(sessionResponse)) return undefined; - const configOptions = sessionResponse.configOptions; - if (!Array.isArray(configOptions)) return undefined; - for (const opt of configOptions) { - if (isRecord(opt) && opt.category === "model" && typeof opt.id === "string") { - return opt.id; - } - } - return undefined; -} - function toMessage(cause: unknown, fallback: string): string { if (cause instanceof Error && cause.message.length > 0) { return cause.message; @@ -102,351 +126,6 @@ function toMessage(cause: unknown, fallback: string): string { return fallback; } -function mapAcpToAdapterError( - threadId: ThreadId, - method: string, - error: AcpError, -): ProviderAdapterError { - if (Schema.is(AcpProcessExitedError)(error)) { - return new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - cause: error, - }); - } - if (Schema.is(AcpRpcError)(error)) { - return new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: error.message, - cause: error, - }); - } - return new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: toMessage(error, `${method} failed`), - cause: error, - }); -} - -function acpPermissionOutcome(decision: ProviderApprovalDecision): string { - switch (decision) { - case "acceptForSession": - return "allow-always"; - case "accept": - return "allow-once"; - case "decline": - case "cancel": - default: - return "reject-once"; - } -} - -interface AcpSessionMode { - readonly id: string; - readonly name: string; - readonly description?: string; -} - -interface AcpSessionModeState { - readonly currentModeId: string; - readonly availableModes: ReadonlyArray; -} - -interface AcpToolCallState { - readonly toolCallId: string; - readonly itemType: ToolLifecycleItemType; - readonly title?: string; - readonly status?: "pending" | "inProgress" | "completed" | "failed"; - readonly command?: string; - readonly detail?: string; - readonly data: Record; -} - -function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" { - switch (raw) { - case "completed": - return "completed"; - case "in_progress": - case "inProgress": - return "inProgress"; - default: - return "pending"; - } -} - -function normalizeToolCallStatus( - raw: unknown, - fallback?: "pending" | "inProgress" | "completed" | "failed", -): "pending" | "inProgress" | "completed" | "failed" | undefined { - switch (raw) { - case "pending": - return "pending"; - case "in_progress": - case "inProgress": - return "inProgress"; - case "completed": - return "completed"; - case "failed": - return "failed"; - default: - return fallback; - } -} - -function runtimeItemStatusFromToolCallStatus( - status: "pending" | "inProgress" | "completed" | "failed" | undefined, -): "inProgress" | "completed" | "failed" | undefined { - switch (status) { - case "pending": - case "inProgress": - return "inProgress"; - case "completed": - return "completed"; - case "failed": - return "failed"; - default: - return undefined; - } -} - -function normalizeCommandValue(value: unknown): string | undefined { - if (typeof value === "string" && value.trim().length > 0) { - return value.trim(); - } - if (!Array.isArray(value)) { - return undefined; - } - const parts = value - .map((entry) => (typeof entry === "string" && entry.trim().length > 0 ? entry.trim() : null)) - .filter((entry): entry is string => entry !== null); - return parts.length > 0 ? parts.join(" ") : undefined; -} - -function extractCommandFromTitle(title: string | undefined): string | undefined { - if (!title) { - return undefined; - } - const match = /`([^`]+)`/.exec(title); - return match?.[1]?.trim() || undefined; -} - -function extractToolCallCommand(rawInput: unknown, title: string | undefined): string | undefined { - if (isRecord(rawInput)) { - const directCommand = normalizeCommandValue(rawInput.command); - if (directCommand) { - return directCommand; - } - const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : ""; - const args = normalizeCommandValue(rawInput.args); - if (executable && args) { - return `${executable} ${args}`; - } - if (executable) { - return executable; - } - } - return extractCommandFromTitle(title); -} - -function extractTextContentFromToolCallContent(content: unknown): string | undefined { - if (!Array.isArray(content)) { - return undefined; - } - const chunks = content - .map((entry) => { - if (!isRecord(entry)) { - return undefined; - } - if (entry.type !== "content") { - return undefined; - } - const nestedContent = entry.content; - if (!isRecord(nestedContent) || nestedContent.type !== "text") { - return undefined; - } - return typeof nestedContent.text === "string" && nestedContent.text.trim().length > 0 - ? nestedContent.text.trim() - : undefined; - }) - .filter((entry): entry is string => entry !== undefined); - return chunks.length > 0 ? chunks.join("\n") : undefined; -} - -function toolLifecycleItemTypeFromKind(kind: unknown): ToolLifecycleItemType { - switch (kind) { - case "execute": - return "command_execution"; - case "edit": - case "delete": - case "move": - return "file_change"; - case "search": - case "fetch": - return "web_search"; - default: - return "dynamic_tool_call"; - } -} - -function requestTypeFromToolKind( - kind: unknown, -): "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" { - switch (kind) { - case "execute": - return "exec_command_approval"; - case "read": - return "file_read_approval"; - case "edit": - case "delete": - case "move": - return "file_change_approval"; - default: - return "unknown"; - } -} - -function parseToolCallState( - raw: unknown, - options?: { - readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; - }, -): AcpToolCallState | undefined { - if (!isRecord(raw)) { - return undefined; - } - const toolCallId = typeof raw.toolCallId === "string" ? raw.toolCallId.trim() : ""; - if (!toolCallId) { - return undefined; - } - const title = - typeof raw.title === "string" && raw.title.trim().length > 0 ? raw.title.trim() : undefined; - const command = extractToolCallCommand(raw.rawInput, title); - const textContent = extractTextContentFromToolCallContent(raw.content); - const normalizedTitle = - title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" - ? title - : undefined; - const detail = command ?? normalizedTitle ?? textContent; - const data: Record = { toolCallId }; - if (typeof raw.kind === "string" && raw.kind.trim().length > 0) { - data.kind = raw.kind.trim(); - } - if (command) { - data.command = command; - } - if (raw.rawInput !== undefined) { - data.rawInput = raw.rawInput; - } - if (raw.rawOutput !== undefined) { - data.rawOutput = raw.rawOutput; - } - if (raw.content !== undefined) { - data.content = raw.content; - } - if (raw.locations !== undefined) { - data.locations = raw.locations; - } - const status = normalizeToolCallStatus(raw.status, options?.fallbackStatus); - return { - toolCallId, - itemType: toolLifecycleItemTypeFromKind(raw.kind), - ...(title ? { title } : {}), - ...(status ? { status } : {}), - ...(command ? { command } : {}), - ...(detail ? { detail } : {}), - data, - } satisfies AcpToolCallState; -} - -function mergeToolCallState( - previous: AcpToolCallState | undefined, - next: AcpToolCallState, -): AcpToolCallState { - const nextKind = typeof next.data.kind === "string" ? next.data.kind : undefined; - const title = next.title ?? previous?.title; - const status = next.status ?? previous?.status; - const command = next.command ?? previous?.command; - const detail = next.detail ?? previous?.detail; - return { - toolCallId: next.toolCallId, - itemType: nextKind !== undefined ? next.itemType : (previous?.itemType ?? next.itemType), - ...(title ? { title } : {}), - ...(status ? { status } : {}), - ...(command ? { command } : {}), - ...(detail ? { detail } : {}), - data: { - ...previous?.data, - ...next.data, - }, - } satisfies AcpToolCallState; -} - -function parsePermissionRequest(params: unknown): { - requestType: "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown"; - detail?: string; - toolCall?: AcpToolCallState; -} { - if (!isRecord(params)) { - return { requestType: "unknown" }; - } - const toolCall = parseToolCallState(params.toolCall, { fallbackStatus: "pending" }); - const requestType = requestTypeFromToolKind( - isRecord(params.toolCall) ? params.toolCall.kind : undefined, - ); - const detail = - toolCall?.command ?? - toolCall?.title ?? - toolCall?.detail ?? - (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); - return { - requestType, - ...(detail ? { detail } : {}), - ...(toolCall ? { toolCall } : {}), - }; -} - -function parseSessionModeState(raw: unknown): AcpSessionModeState | undefined { - if (!isRecord(raw)) return undefined; - const modes = isRecord(raw.modes) ? raw.modes : raw; - const currentModeId = - typeof modes.currentModeId === "string" && modes.currentModeId.trim().length > 0 - ? modes.currentModeId.trim() - : undefined; - if (!currentModeId) { - return undefined; - } - const rawModes = modes.availableModes; - if (!Array.isArray(rawModes)) { - return undefined; - } - const availableModes = rawModes - .map((mode) => { - if (!isRecord(mode)) return undefined; - const id = typeof mode.id === "string" ? mode.id.trim() : ""; - const name = typeof mode.name === "string" ? mode.name.trim() : ""; - if (!id || !name) { - return undefined; - } - const description = - typeof mode.description === "string" && mode.description.trim().length > 0 - ? mode.description.trim() - : undefined; - return description !== undefined - ? ({ id, name, description } satisfies AcpSessionMode) - : ({ id, name } satisfies AcpSessionMode); - }) - .filter((mode): mode is AcpSessionMode => mode !== undefined); - if (availableModes.length === 0) { - return undefined; - } - return { - currentModeId, - availableModes, - }; -} - function normalizeModeSearchText(mode: AcpSessionMode): string { return [mode.id, mode.name, mode.description] .filter((value): value is string => typeof value === "string" && value.length > 0) @@ -515,172 +194,10 @@ function resolveRequestedModeId(input: { ); } -function updateSessionModeState( - modeState: AcpSessionModeState | undefined, - nextModeId: string, -): AcpSessionModeState | undefined { - if (!modeState) { - return undefined; - } - const normalizedModeId = nextModeId.trim(); - if (!normalizedModeId) { - return modeState; - } - return modeState.availableModes.some((mode) => mode.id === normalizedModeId) - ? { - ...modeState, - currentModeId: normalizedModeId, - } - : modeState; -} - -function isMethodNotFoundRpcError(error: AcpError): boolean { - return ( - Schema.is(AcpRpcError)(error) && - (error.code === -32601 || error.message.toLowerCase().includes("method not found")) - ); -} - -function parseSessionUpdate(params: unknown): { - sessionUpdate?: string; - text?: string; - modeId?: string; - plan?: { - explanation?: string | null; - plan: ReadonlyArray<{ step: string; status: "pending" | "inProgress" | "completed" }>; - }; - toolCall?: AcpToolCallState; -} { - if (!isRecord(params)) return {}; - const upd = params.update; - if (!isRecord(upd)) return {}; - const su = typeof upd.sessionUpdate === "string" ? upd.sessionUpdate : undefined; - const modeId = - typeof upd.modeId === "string" - ? upd.modeId - : typeof upd.currentModeId === "string" - ? upd.currentModeId - : undefined; - if (su === "plan") { - const entries = Array.isArray(upd.entries) ? upd.entries : undefined; - const plan = - entries - ?.map((entry, index) => { - if (!isRecord(entry)) { - return undefined; - } - const step = - typeof entry.content === "string" && entry.content.trim().length > 0 - ? entry.content.trim() - : `Step ${index + 1}`; - return { - step, - status: normalizePlanStepStatus(entry.status), - } as const; - }) - .filter( - ( - entry, - ): entry is { - step: string; - status: "pending" | "inProgress" | "completed"; - } => entry !== undefined, - ) ?? []; - if (plan.length > 0) { - const explanation = - typeof upd.explanation === "string" - ? upd.explanation - : upd.explanation === null - ? null - : undefined; - return { - sessionUpdate: su, - ...(modeId !== undefined ? { modeId } : {}), - plan: { - ...(explanation !== undefined ? { explanation } : {}), - plan, - }, - }; - } - } - if (su === "tool_call" || su === "tool_call_update") { - const toolCall = parseToolCallState( - upd, - su === "tool_call" ? { fallbackStatus: "pending" } : undefined, - ); - if (toolCall) { - return { - sessionUpdate: su, - ...(modeId !== undefined ? { modeId } : {}), - toolCall, - }; - } - } - const content = upd.content; - if (!isRecord(content)) { - return { - ...(su !== undefined ? { sessionUpdate: su } : {}), - ...(modeId !== undefined ? { modeId } : {}), - }; - } - const text = typeof content.text === "string" ? content.text : undefined; - if (su !== undefined && text !== undefined) { - return { - sessionUpdate: su, - text, - ...(modeId !== undefined ? { modeId } : {}), - }; - } - if (su !== undefined) { - return { - sessionUpdate: su, - ...(modeId !== undefined ? { modeId } : {}), - }; - } - if (text !== undefined) { - return { - text, - ...(modeId !== undefined ? { modeId } : {}), - }; - } - return {}; -} - -interface PendingApproval { - readonly decision: Deferred.Deferred; - readonly requestType: - | "exec_command_approval" - | "file_read_approval" - | "file_change_approval" - | "unknown"; -} - -interface PendingUserInput { - readonly answers: Deferred.Deferred; -} - -interface CursorSessionContext { - readonly threadId: ThreadId; - session: ProviderSession; - readonly child: ChildProcessWithoutNullStreams; - readonly conn: AcpJsonRpcConnection; - acpSessionId: string; - /** ACP configId for the model selector (discovered from session/new configOptions). */ - modelConfigId: string | undefined; - notificationFiber: Fiber.Fiber | undefined; - readonly pendingApprovals: Map; - readonly pendingUserInputs: Map; - readonly turns: Array<{ id: TurnId; items: Array }>; - readonly toolCalls: Map; - modeState: AcpSessionModeState | undefined; - lastPlanFingerprint: string | undefined; - activeTurnId: TurnId | undefined; - stopped: boolean; -} - function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); const serverSettingsService = yield* ServerSettingsService; const nativeEventLogger = @@ -701,107 +218,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const offerRuntimeEvent = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); - const emitPlanUpdate = ( - ctx: CursorSessionContext, - payload: { - explanation?: string | null; - plan: ReadonlyArray<{ step: string; status: "pending" | "inProgress" | "completed" }>; - }, - rawPayload: unknown, - source: "acp.jsonrpc" | "acp.cursor.extension", - method: string, - ) => - Effect.gen(function* () { - const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${JSON.stringify(payload)}`; - if (ctx.lastPlanFingerprint === fingerprint) { - return; - } - ctx.lastPlanFingerprint = fingerprint; - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.plan.updated", - ...stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - payload, - raw: { - source, - method, - payload: rawPayload, - }, - }); - }); - - const emitToolCallEvent = ( - ctx: CursorSessionContext, - toolCall: AcpToolCallState, - rawPayload: unknown, - ) => - Effect.gen(function* () { - const runtimeStatus = runtimeItemStatusFromToolCallStatus(toolCall.status); - const payload = { - itemType: toolCall.itemType, - ...(runtimeStatus ? { status: runtimeStatus } : {}), - ...(toolCall.title ? { title: toolCall.title } : {}), - ...(toolCall.detail ? { detail: toolCall.detail } : {}), - ...(Object.keys(toolCall.data).length > 0 ? { data: toolCall.data } : {}), - }; - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: - toolCall.status === "completed" || toolCall.status === "failed" - ? "item.completed" - : "item.updated", - ...stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: RuntimeItemId.makeUnsafe(toolCall.toolCallId), - payload, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: rawPayload, - }, - }); - if (toolCall.status === "completed" || toolCall.status === "failed") { - ctx.toolCalls.delete(toolCall.toolCallId); - } - }); - - const setSessionMode = (ctx: CursorSessionContext, modeId: string | undefined) => - Effect.gen(function* () { - const normalizedModeId = modeId?.trim(); - if (!normalizedModeId) { - return; - } - if (ctx.modeState?.currentModeId === normalizedModeId) { - return; - } - const setModeParams = { sessionId: ctx.acpSessionId, modeId: normalizedModeId }; - const setModeExit = yield* Effect.exit(ctx.conn.request("session/set_mode", setModeParams)); - if (Exit.isSuccess(setModeExit)) { - ctx.modeState = updateSessionModeState(ctx.modeState, normalizedModeId); - return; - } - const error = Cause.squash(setModeExit.cause) as AcpError; - if (!isMethodNotFoundRpcError(error)) { - return yield* mapAcpToAdapterError(ctx.threadId, "session/set_mode", error); - } - yield* ctx.conn - .request("session/mode/set", { - sessionId: ctx.acpSessionId, - mode: normalizedModeId, - }) - .pipe( - Effect.mapError((cause) => - mapAcpToAdapterError(ctx.threadId, "session/mode/set", cause), - ), - ); - ctx.modeState = updateSessionModeState(ctx.modeState, normalizedModeId); - }); - const logNative = ( threadId: ThreadId, method: string, @@ -828,16 +244,46 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ); }); + const emitPlanUpdate = ( + ctx: CursorSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | "acp.cursor.extension", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${JSON.stringify(payload)}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + const requireSession = ( threadId: ThreadId, ): Effect.Effect => { const ctx = sessions.get(threadId); if (!ctx || ctx.stopped) { return Effect.fail( - new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }), + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), ); } return Effect.succeed(ctx); @@ -850,12 +296,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { if (ctx.notificationFiber) { yield* Fiber.interrupt(ctx.notificationFiber); } - disposeAcpChild(ctx.child); + yield* Effect.ignore(ctx.acp.close); sessions.delete(ctx.threadId); - const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.exited", - ...stamp, + ...(yield* makeEventStamp()), provider: PROVIDER, threadId: ctx.threadId, payload: { exitKind: "graceful" }, @@ -878,7 +323,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { issue: "cwd is required and must be non-empty.", }); } + const cwd = nodePath.resolve(input.cwd.trim()); + const cursorModelSelection = + input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + const spawnOptions = yield* serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.providers.cursor), Effect.map((cursorSettings) => ({ @@ -899,279 +352,172 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }), ), ); - const cursorModelSelection = - input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; - const existing = sessions.get(input.threadId); - if (existing && !existing.stopped) { - yield* stopSessionInternal(existing); - } - - const child = yield* spawnAcpChildProcess(spawnOptions).pipe( - Effect.mapError( - (e) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: e.message, - cause: e, - }), - ), - ); - const conn = yield* attachAcpJsonRpcConnection(child).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: "Failed to attach ACP JSON-RPC to child process.", - cause, - }), - ), - ); + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + let ctx!: CursorSessionContext; - const ctx: CursorSessionContext = { - threadId: input.threadId, - session: {} as ProviderSession, - child, - conn, - acpSessionId: "", - modelConfigId: undefined, - notificationFiber: undefined, - pendingApprovals: new Map(), - pendingUserInputs: new Map(), - turns: [], - toolCalls: new Map(), - modeState: undefined, - lastPlanFingerprint: undefined, - activeTurnId: undefined, - stopped: false, - }; + const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; - const registerHandlers = (ctx: CursorSessionContext) => - Effect.gen(function* () { - yield* conn.registerHandler("session/request_permission", (params, _acpId) => + const acp = yield* makeAcpSessionRuntime({ + spawn: spawnOptions, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + authMethodId: "cursor_login", + handlers: { + extRequests: { + "cursor/ask_question": defineExtRequest(CursorAskQuestionRequest, (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/ask_question", + params, + "acp.cursor.extension", + ); + const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); + const answers = yield* Deferred.make(); + pendingUserInputs.set(requestId, { answers }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { questions: extractAskQuestions(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/ask_question", + payload: params, + }, + }); + const resolved = yield* Deferred.await(answers); + pendingUserInputs.delete(requestId); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: resolved }, + }); + return { answers: resolved }; + }), + ), + "cursor/create_plan": defineExtRequest(CursorCreatePlanRequest, (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/create_plan", + params, + "acp.cursor.extension", + ); + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + payload: { planMarkdown: extractPlanMarkdown(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/create_plan", + payload: params, + }, + }); + return { accepted: true } as const; + }), + ), + "cursor/update_todos": defineExtRequest(CursorUpdateTodosRequest, (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/update_todos", + params, + "acp.cursor.extension", + ); + if (ctx) { + yield* emitPlanUpdate( + ctx, + extractTodosAsPlan(params), + params, + "acp.cursor.extension", + "cursor/update_todos", + ); + } + return {}; + }), + ), + }, + requestPermission: (params) => Effect.gen(function* () { - yield* logNative(ctx.threadId, "session/request_permission", params, "acp.jsonrpc"); + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); const permissionRequest = parsePermissionRequest(params); - if (permissionRequest.toolCall) { - const previousToolCall = ctx.toolCalls.get(permissionRequest.toolCall.toolCallId); - ctx.toolCalls.set( - permissionRequest.toolCall.toolCallId, - mergeToolCallState(previousToolCall, permissionRequest.toolCall), - ); - } const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); const decision = yield* Deferred.make(); - ctx.pendingApprovals.set(requestId, { + pendingApprovals.set(requestId, { decision, requestType: permissionRequest.requestType, }); - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "request.opened", - ...stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - requestId: runtimeRequestId, - payload: { + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, requestType: permissionRequest.requestType, - ...(permissionRequest.detail - ? { detail: permissionRequest.detail } - : { detail: JSON.stringify(params).slice(0, 2000) }), + detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000), args: params, - }, - raw: { source: "acp.jsonrpc", method: "session/request_permission", - payload: params, - }, - }); - const d = yield* Deferred.await(decision); - ctx.pendingApprovals.delete(requestId); - const stamp2 = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "request.resolved", - ...stamp2, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - requestId: runtimeRequestId, - payload: { - requestType: permissionRequest.requestType, - decision: d, - }, - }); - return { - outcome: { outcome: "selected", optionId: acpPermissionOutcome(d) }, - }; - }), - ); - - yield* conn.registerHandler("cursor/ask_question", (params, _acpId) => - Effect.gen(function* () { - yield* logNative( - ctx.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", + rawPayload: params, + }), ); - const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); - const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); - const answers = yield* Deferred.make(); - ctx.pendingUserInputs.set(requestId, { answers }); - const questions = extractAskQuestions(params); - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - requestId: runtimeRequestId, - payload: { questions }, - raw: { - source: "acp.cursor.extension", - method: "cursor/ask_question", - payload: params, - }, - }); - const a = yield* Deferred.await(answers); - ctx.pendingUserInputs.delete(requestId); - const stamp2 = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "user-input.resolved", - ...stamp2, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - requestId: runtimeRequestId, - payload: { answers: a }, - }); - return { answers: a }; - }), - ); - - yield* conn.registerHandler("cursor/create_plan", (params, _acpId) => - Effect.gen(function* () { - yield* logNative( - ctx.threadId, - "cursor/create_plan", - params, - "acp.cursor.extension", + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + requestType: permissionRequest.requestType, + decision: resolved, + }), ); - const planMarkdown = extractPlanMarkdown(params); - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.proposed.completed", - ...stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - payload: { planMarkdown }, - raw: { - source: "acp.cursor.extension", - method: "cursor/create_plan", - payload: params, + return { + outcome: { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), }, - }); - return { accepted: true }; - }), - ); - - yield* conn.registerHandler("cursor/update_todos", (params, _acpId) => - Effect.gen(function* () { - yield* logNative( - ctx.threadId, - "cursor/update_todos", - params, - "acp.cursor.extension", - ); - const plan = extractTodosAsPlan(params); - yield* emitPlanUpdate( - ctx, - plan, - params, - "acp.cursor.extension", - "cursor/update_todos", - ); - return {}; - }), - ); - }); - - yield* registerHandlers(ctx); - - const init = yield* conn - .request("initialize", { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, - clientInfo: { name: "t3-code", version: "0.0.0" }, - }) - .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "initialize", e))); - - yield* conn - .request("authenticate", { methodId: "cursor_login" }) - .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "authenticate", e))); - - const resume = parseCursorResume(input.resumeCursor); - let acpSessionId: string; - let sessionSetupResult: unknown = undefined; - if (resume) { - const loadExit = yield* Effect.exit( - conn.request("session/load", { - sessionId: resume.sessionId, - cwd, - mcpServers: [], - }), - ); - if (Exit.isSuccess(loadExit)) { - acpSessionId = resume.sessionId; - sessionSetupResult = loadExit.value; - } else { - const created = yield* conn - .request("session/new", { cwd, mcpServers: [] }) - .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "session/new", e))); - const cr = created as { sessionId?: string }; - if (typeof cr.sessionId !== "string") { - return yield* new ProviderAdapterRequestError({ + }; + }).pipe(Effect.mapError(EffectAcpErrors.normalizeAcpError)), + }, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ provider: PROVIDER, - method: "session/new", - detail: "session/new missing sessionId", - cause: created, - }); - } - acpSessionId = cr.sessionId; - sessionSetupResult = created; - } - } else { - const created = yield* conn - .request("session/new", { cwd, mcpServers: [] }) - .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "session/new", e))); - const cr = created as { sessionId?: string }; - if (typeof cr.sessionId !== "string") { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/new", - detail: "session/new missing sessionId", - cause: created, - }); - } - acpSessionId = cr.sessionId; - sessionSetupResult = created; - } + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); const now = yield* nowIso; - const resumeCursor = { - schemaVersion: CURSOR_RESUME_VERSION, - sessionId: acpSessionId, - }; - const session: ProviderSession = { provider: PROVIDER, status: "ready", @@ -1179,75 +525,83 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { cwd, model: cursorModelSelection?.model, threadId: input.threadId, - resumeCursor, + resumeCursor: { + schemaVersion: CURSOR_RESUME_VERSION, + sessionId: acp.sessionId, + }, createdAt: now, updatedAt: now, }; - ctx.session = session; - ctx.acpSessionId = acpSessionId; - ctx.modelConfigId = extractModelConfigId(sessionSetupResult); - ctx.modeState = parseSessionModeState(sessionSetupResult); - - const handleNotification = (msg: AcpInboundMessage) => - Effect.gen(function* () { - if (msg._tag !== "notification" || msg.method !== "session/update") return; - yield* logNative(ctx.threadId, "session/update", msg.params, "acp.jsonrpc"); - const p = parseSessionUpdate(msg.params); - if (p.modeId) { - ctx.modeState = updateSessionModeState(ctx.modeState, p.modeId); - } - if (p.sessionUpdate === "plan" && p.plan) { - yield* emitPlanUpdate(ctx, p.plan, msg.params, "acp.jsonrpc", "session/update"); - } - if ( - (p.sessionUpdate === "tool_call" || p.sessionUpdate === "tool_call_update") && - p.toolCall - ) { - const previousToolCall = ctx.toolCalls.get(p.toolCall.toolCallId); - const mergedToolCall = mergeToolCallState(previousToolCall, p.toolCall); - ctx.toolCalls.set(mergedToolCall.toolCallId, mergedToolCall); - yield* emitToolCallEvent(ctx, mergedToolCall, msg.params); - } - if ( - (p.sessionUpdate === "agent_message_chunk" || - p.sessionUpdate === "assistant_message_chunk") && - p.text - ) { - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "content.delta", - ...stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - payload: { - streamKind: "assistant_text", - delta: p.text, - }, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: msg.params, - }, - }); - } - }); + ctx = { + threadId: input.threadId, + session, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + stopped: false, + }; const nf = yield* Stream.runDrain( - Stream.mapEffect(conn.notifications, handleNotification), + Stream.mapEffect(acp.events, (event) => + Effect.gen(function* () { + switch (event._tag) { + case "ModeChanged": + return; + case "PlanUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), ).pipe(Effect.forkChild); ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); - const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.started", - ...stamp, + ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - payload: { resume: init }, + payload: { resume: acp.initializeResult }, }); yield* offerRuntimeEvent({ type: "session.state.changed", @@ -1261,25 +615,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - payload: { providerThreadId: acpSessionId }, + payload: { providerThreadId: acp.sessionId }, }); return session; }); - const setSessionModel = (ctx: CursorSessionContext, model: string) => - Effect.gen(function* () { - const configId = ctx.modelConfigId ?? "model"; - yield* ctx.conn - .request("session/set_config_option", { - sessionId: ctx.acpSessionId, - configId, - value: model, - }) - .pipe(Effect.ignore); - ctx.session = { ...ctx.session, model, updatedAt: yield* nowIso }; - }); - const sendTurn: CursorAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); @@ -1291,10 +632,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { turnModelSelection?.options, ); - yield* setSessionModel(ctx, model); + yield* ctx.acp + .setModel(model) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", error), + ), + ); ctx.activeTurnId = turnId; ctx.lastPlanFingerprint = undefined; - ctx.toolCalls.clear(); ctx.session = { ...ctx.session, activeTurnId: turnId, @@ -1304,21 +650,30 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const requestedModeId = resolveRequestedModeId({ interactionMode: input.interactionMode, runtimeMode: ctx.session.runtimeMode, - modeState: ctx.modeState, + modeState: yield* ctx.acp.getModeState, }); - yield* Effect.ignore(setSessionMode(ctx, requestedModeId)); + if (requestedModeId) { + yield* Effect.ignore( + ctx.acp + .setMode(requestedModeId) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", error), + ), + ), + ); + } - const stampStart = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.started", - ...stampStart, + ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, turnId, payload: { model }, }); - const promptParts: Array> = []; + const promptParts: Array = []; if (input.input?.trim()) { promptParts.push({ type: "text", text: input.input.trim() }); } @@ -1348,10 +703,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ); promptParts.push({ type: "image", - image: { - data: Buffer.from(bytes).toString("base64"), - mimeType: attachment.mimeType, - }, + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, }); } } @@ -1364,12 +717,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); } - const result = yield* ctx.conn - .request("session/prompt", { - sessionId: ctx.acpSessionId, + const result = yield* ctx.acp + .prompt({ prompt: promptParts, }) - .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "session/prompt", e))); + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); ctx.session = { @@ -1379,17 +735,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { model, }; - const pr = result as { stopReason?: string | null }; - const stampEnd = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.completed", - ...stampEnd, + ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, turnId, payload: { state: "completed", - stopReason: pr.stopReason ?? null, + stopReason: result.stopReason ?? null, }, }); @@ -1403,7 +757,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); - yield* Effect.ignore(ctx.conn.request("session/cancel", { sessionId: ctx.acpSessionId })); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); }); const respondToRequest: CursorAdapterShape["respondToRequest"] = ( @@ -1445,10 +805,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const readThread: CursorAdapterShape["readThread"] = (threadId) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); - return { - threadId, - turns: ctx.turns, - }; + return { threadId, turns: ctx.turns }; }); const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => @@ -1509,77 +866,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); } -function extractAskQuestions(params: unknown): ReadonlyArray { - if (!isRecord(params)) return []; - const qs = params.questions ?? params.question; - if (!Array.isArray(qs)) return []; - const out: UserInputQuestion[] = []; - for (const q of qs) { - if (!isRecord(q)) continue; - const id = typeof q.id === "string" ? q.id : "question"; - const header = typeof q.header === "string" ? q.header : "Question"; - const question = typeof q.question === "string" ? q.question : ""; - const rawOpts = q.options; - const options: Array<{ label: string; description: string }> = []; - if (Array.isArray(rawOpts)) { - for (const o of rawOpts) { - if (!isRecord(o)) continue; - const label = typeof o.label === "string" ? o.label : "Option"; - const description = typeof o.description === "string" ? o.description : label; - options.push({ label, description }); - } - } - if (options.length === 0) { - options.push({ label: "OK", description: "Continue" }); - } - out.push({ id, header, question, options }); - } - return out.length > 0 - ? out - : [{ id: "q1", header: "Input", question: "?", options: [{ label: "OK", description: "OK" }] }]; -} - -function extractPlanMarkdown(params: unknown): string { - if (!isRecord(params)) return ""; - const pm = - typeof params.plan === "string" - ? params.plan - : typeof params.planMarkdown === "string" - ? params.planMarkdown - : typeof params.markdown === "string" - ? params.markdown - : ""; - return pm || "# Plan\n\n(Cursor did not supply plan text.)"; -} - -function extractTodosAsPlan(params: unknown): { - explanation?: string; - plan: ReadonlyArray<{ step: string; status: "pending" | "inProgress" | "completed" }>; -} { - if (!isRecord(params)) { - return { plan: [] }; - } - const todos = params.todos ?? params.items; - if (!Array.isArray(todos)) { - return { plan: [] }; - } - const plan = todos.map((t, i) => { - if (!isRecord(t)) { - return { step: `Step ${i + 1}`, status: "pending" as const }; - } - const step = - typeof t.content === "string" - ? t.content - : typeof t.title === "string" - ? t.title - : `Step ${i + 1}`; - const st = t.status; - const status = normalizePlanStepStatus(st); - return { step, status }; - }); - return { plan }; -} - export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter()); export function makeCursorAdapterLive(opts?: CursorAdapterLiveOptions) { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index bed25977d6b..ec61079618a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -403,6 +403,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( if (joined === "login status") { return { stdout: "Logged in\n", stderr: "", code: 0 }; } + if (joined === "about") { + return { + stdout: "Version: 1.0.0\nUser Email: user@example.com\n", + stderr: "", + code: 0, + }; + } throw new Error(`Unexpected args: ${joined}`); }), ), diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts new file mode 100644 index 00000000000..b6c9aec770a --- /dev/null +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -0,0 +1,107 @@ +import { RuntimeRequestId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + acpPermissionOutcome, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpToolCallEvent, +} from "./AcpAdapterSupport.ts"; + +describe("AcpAdapterSupport", () => { + it("maps ACP approval decisions to permission outcomes", () => { + expect(acpPermissionOutcome("accept")).toBe("allow-once"); + expect(acpPermissionOutcome("acceptForSession")).toBe("allow-always"); + expect(acpPermissionOutcome("decline")).toBe("reject-once"); + }); + + it("builds shared ACP-backed runtime events", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + const turnId = TurnId.makeUnsafe("turn-1"); + + expect( + makeAcpRequestOpenedEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId, + requestId: RuntimeRequestId.makeUnsafe("request-1"), + requestType: "exec_command_approval", + detail: "cat package.json", + args: { command: ["cat", "package.json"] }, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "request.opened", + provider: "cursor", + turnId, + payload: { + requestType: "exec_command_approval", + detail: "cat package.json", + }, + }); + + expect( + makeAcpPlanUpdatedEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId, + payload: { + plan: [{ step: "Inspect state", status: "inProgress" }], + }, + source: "acp.cursor.extension", + method: "cursor/update_todos", + rawPayload: { todos: [] }, + }), + ).toMatchObject({ + type: "turn.plan.updated", + raw: { + method: "cursor/update_todos", + }, + }); + + expect( + makeAcpToolCallEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId, + toolCall: { + toolCallId: "tool-1", + itemType: "command_execution", + status: "completed", + title: "Terminal", + detail: "bun run test", + data: { command: "bun run test" }, + }, + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "item.completed", + payload: { + itemType: "command_execution", + status: "completed", + }, + }); + + expect( + makeAcpContentDeltaEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId, + text: "hello", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "content.delta", + payload: { + delta: "hello", + }, + }); + }); +}); diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts new file mode 100644 index 00000000000..7f5664a8a6a --- /dev/null +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -0,0 +1,239 @@ +import { + RuntimeItemId, + type EventId, + type ProviderApprovalDecision, + type ProviderKind, + type ProviderRuntimeEvent, + type ThreadId, + type TurnId, + type RuntimeRequestId, +} from "@t3tools/contracts"; +import { Schema } from "effect"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + type ProviderAdapterError, +} from "../Errors.ts"; +import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; + +export type AcpAdapterRawSource = "acp.jsonrpc" | `acp.${string}.extension`; + +export interface AcpEventStamp { + readonly eventId: EventId; + readonly createdAt: string; +} + +function runtimeItemStatusFromToolCallStatus( + status: "pending" | "inProgress" | "completed" | "failed" | undefined, +): "inProgress" | "completed" | "failed" | undefined { + switch (status) { + case "pending": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return undefined; + } +} + +export function mapAcpToAdapterError( + provider: ProviderKind, + threadId: ThreadId, + method: string, + error: EffectAcpErrors.AcpError, +): ProviderAdapterError { + if (Schema.is(EffectAcpErrors.AcpProcessExitedError)(error)) { + return new ProviderAdapterSessionClosedError({ + provider, + threadId, + cause: error, + }); + } + if (Schema.is(EffectAcpErrors.AcpRequestError)(error)) { + return new ProviderAdapterRequestError({ + provider, + method, + detail: error.message, + cause: error, + }); + } + return new ProviderAdapterRequestError({ + provider, + method, + detail: error.message, + cause: error, + }); +} + +export function acpPermissionOutcome(decision: ProviderApprovalDecision): string { + switch (decision) { + case "acceptForSession": + return "allow-always"; + case "accept": + return "allow-once"; + case "decline": + case "cancel": + default: + return "reject-once"; + } +} + +export function makeAcpRequestOpenedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly requestId: RuntimeRequestId; + readonly requestType: + | "exec_command_approval" + | "file_read_approval" + | "file_change_approval" + | "unknown"; + readonly detail: string; + readonly args: unknown; + readonly source: AcpAdapterRawSource; + readonly method: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "request.opened", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + requestId: input.requestId, + payload: { + requestType: input.requestType, + detail: input.detail, + args: input.args, + }, + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + }; +} + +export function makeAcpRequestResolvedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly requestId: RuntimeRequestId; + readonly requestType: + | "exec_command_approval" + | "file_read_approval" + | "file_change_approval" + | "unknown"; + readonly decision: ProviderApprovalDecision; +}): ProviderRuntimeEvent { + return { + type: "request.resolved", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + requestId: input.requestId, + payload: { + requestType: input.requestType, + decision: input.decision, + }, + }; +} + +export function makeAcpPlanUpdatedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }; + readonly source: AcpAdapterRawSource; + readonly method: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "turn.plan.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + payload: input.payload, + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + }; +} + +export function makeAcpToolCallEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly toolCall: AcpToolCallState; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + const runtimeStatus = runtimeItemStatusFromToolCallStatus(input.toolCall.status); + return { + type: + input.toolCall.status === "completed" || input.toolCall.status === "failed" + ? "item.completed" + : "item.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + itemId: RuntimeItemId.makeUnsafe(input.toolCall.toolCallId), + payload: { + itemType: input.toolCall.itemType, + ...(runtimeStatus ? { status: runtimeStatus } : {}), + ...(input.toolCall.title ? { title: input.toolCall.title } : {}), + ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), + ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + }; +} + +export function makeAcpContentDeltaEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly text: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "content.delta", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + payload: { + streamKind: "assistant_text", + delta: input.text, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + }; +} diff --git a/apps/server/src/provider/acp/AcpErrors.ts b/apps/server/src/provider/acp/AcpErrors.ts deleted file mode 100644 index edbc10677d7..00000000000 --- a/apps/server/src/provider/acp/AcpErrors.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Schema } from "effect"; - -export class AcpSpawnError extends Schema.TaggedErrorClass()("AcpSpawnError", { - command: Schema.optional(Schema.String), - args: Schema.optional(Schema.Array(Schema.String)), - shell: Schema.optional(Schema.Boolean), - cause: Schema.optional(Schema.Defect), -}) { - override get message() { - return `Failed to spawn ACP process: ${this.cause instanceof Error ? this.cause.message : String(this.cause)}`; - } -} - -export class AcpParseError extends Schema.TaggedErrorClass()("AcpParseError", { - line: Schema.String, - cause: Schema.optional(Schema.Defect), -}) { - override get message() { - return `Failed to parse ACP message: ${this.line}`; - } -} -export class AcpRpcError extends Schema.TaggedErrorClass()("AcpRpcError", { - code: Schema.Number, - errorMessage: Schema.optional(Schema.String), - data: Schema.optional(Schema.Unknown), -}) { - override get message() { - return `Failed to send ACP RPC message (code: ${this.code}, message: ${this.errorMessage}, data: ${JSON.stringify(this.data)})`; - } -} - -export class AcpProcessExitedError extends Schema.TaggedErrorClass()( - "AcpProcessExitedError", - { - code: Schema.NullOr(Schema.Number), - signal: Schema.NullOr(Schema.String), - }, -) { - override get message() { - return `ACP process exited with code ${this.code} and signal ${this.signal}`; - } -} - -export const AcpError = Schema.Union([ - AcpSpawnError, - AcpParseError, - AcpRpcError, - AcpProcessExitedError, -]); - -export type AcpError = typeof AcpError.Type; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 3ad8bbb7111..cba58bb2eb8 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -6,46 +6,42 @@ import { it } from "@effect/vitest"; import { Effect, Stream } from "effect"; import { describe, expect } from "vitest"; -import { makeAcpJsonRpcConnection } from "./AcpJsonRpcConnection.ts"; +import { makeAcpSessionRuntime } from "./AcpSessionRuntime.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const bunExe = "bun"; -describe("AcpJsonRpcConnection", () => { - it.effect("performs initialize → session/new → session/prompt against mock agent", () => +describe("AcpSessionRuntime", () => { + it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => Effect.gen(function* () { - const conn = yield* makeAcpJsonRpcConnection({ - command: process.execPath, - args: [mockAgentPath], - }); - - const initResult = yield* conn.request("initialize", { - protocolVersion: 1, - clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + const runtime = yield* makeAcpSessionRuntime({ + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, }); - expect(initResult).toMatchObject({ protocolVersion: 1 }); - yield* conn.request("authenticate", { methodId: "cursor_login" }); + expect(runtime.initializeResult).toMatchObject({ protocolVersion: 1 }); + expect(runtime.sessionId).toBe("mock-session-1"); - const newResult = yield* conn.request("session/new", { - cwd: process.cwd(), - mcpServers: [], - }); - expect(newResult).toEqual({ sessionId: "mock-session-1" }); - - const promptResult = yield* conn.request("session/prompt", { - sessionId: "mock-session-1", + const promptResult = yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }], }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); - const notes = yield* Stream.runCollect(Stream.take(conn.notifications, 1)); - expect(notes.length).toBe(1); - expect(notes[0]?._tag).toBe("notification"); - if (notes[0]?._tag === "notification") { - expect(notes[0].method).toBe("session/update"); + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.events, 2))); + expect(notes).toHaveLength(2); + expect(notes.map((note) => note._tag)).toEqual(["PlanUpdated", "ContentDelta"]); + const planUpdate = notes.find((note) => note._tag === "PlanUpdated"); + expect(planUpdate?._tag).toBe("PlanUpdated"); + if (planUpdate?._tag === "PlanUpdated") { + expect(planUpdate.payload.plan).toHaveLength(2); } + + yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.ts deleted file mode 100644 index bb73a26f49e..00000000000 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { createInterface } from "node:readline"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; - -import { - Cause, - Deferred, - Effect, - Exit, - Queue, - Ref, - Schema, - Scope, - Semaphore, - Stream, -} from "effect"; - -import { - AcpError, - AcpParseError, - AcpProcessExitedError, - AcpRpcError, - AcpSpawnError, -} from "./AcpErrors.ts"; -import { - decodeAcpInboundFromJsonLine, - type AcpInboundMessage, - type AcpServerRequestHandler, - type AcpSpawnInput, -} from "./AcpTypes.ts"; - -const JSON_RPC_VERSION = "2.0"; - -function parseInboundLine(line: string): Effect.Effect { - const trimmed = line.trim(); - if (!trimmed) { - return Effect.succeed(null); - } - const lineSnippet = trimmed.slice(0, 500); - return decodeAcpInboundFromJsonLine(trimmed).pipe( - Effect.mapError((cause) => new AcpParseError({ line: lineSnippet, cause })), - ); -} - -export interface AcpJsonRpcConnection { - readonly request: (method: string, params?: unknown) => Effect.Effect; - readonly notify: (method: string, params?: unknown) => Effect.Effect; - readonly registerHandler: ( - method: string, - handler: AcpServerRequestHandler, - ) => Effect.Effect; - readonly notifications: Stream.Stream; -} - -export function spawnAcpChildProcess( - input: AcpSpawnInput, -): Effect.Effect { - const shell = process.platform === "win32"; - return Effect.try({ - try: () => { - const c = spawn(input.command, [...input.args], { - cwd: input.cwd, - env: { ...process.env, ...input.env }, - stdio: ["pipe", "pipe", "inherit"], - shell, - }); - if (!c.stdin || !c.stdout) { - throw new Error("Child process missing stdio pipes."); - } - return c as unknown as ChildProcessWithoutNullStreams; - }, - catch: (cause) => new AcpSpawnError({ command: input.command, args: input.args, shell, cause }), - }); -} - -export function disposeAcpChild(child: ChildProcessWithoutNullStreams) { - try { - child.stdin?.end(); - } catch { - /* ignore */ - } - try { - child.kill("SIGTERM"); - } catch { - /* ignore */ - } -} - -/** - * Attach JSON-RPC framing to an existing child process (caller owns spawn/kill). - */ -export const attachAcpJsonRpcConnection = ( - child: ChildProcessWithoutNullStreams, -): Effect.Effect => - Effect.gen(function* () { - const writeLock = yield* Semaphore.make(1); - const pending = yield* Ref.make( - new Map>(), - ); - const handlers = yield* Ref.make(new Map()); - const nextId = yield* Ref.make(1); - const notificationQueue = yield* Queue.unbounded(); - - const failAllPending = (error: AcpError) => - Ref.get(pending).pipe( - Effect.flatMap((map) => - Effect.forEach([...map.values()], (def) => Deferred.fail(def, error), { - discard: true, - }), - ), - Effect.tap(() => Ref.set(pending, new Map())), - ); - - const writeRawLine = (payload: Record) => - Effect.try({ - try: () => { - child.stdin.write(`${JSON.stringify(payload)}\n`); - }, - catch: (cause) => - new AcpSpawnError({ - cause, - }), - }); - - const writeSerialized = (payload: Record) => - writeLock.withPermits(1)(writeRawLine(payload)); - - const sendRequest = (method: string, params?: unknown) => - Effect.gen(function* () { - const deferred = yield* Deferred.make(); - yield* writeLock.withPermits(1)( - Effect.gen(function* () { - const id = yield* Ref.get(nextId); - yield* Ref.set(nextId, id + 1); - yield* Ref.update(pending, (map) => new Map(map).set(id, deferred)); - yield* writeRawLine({ - jsonrpc: JSON_RPC_VERSION, - id, - method, - ...(params !== undefined ? { params } : {}), - }); - }), - ); - return yield* Deferred.await(deferred); - }); - - const sendNotify = (method: string, params?: unknown) => - writeSerialized({ - jsonrpc: JSON_RPC_VERSION, - method, - ...(params !== undefined ? { params } : {}), - }).pipe(Effect.asVoid); - - const respondResult = (id: number | string, result: unknown) => - writeSerialized({ jsonrpc: JSON_RPC_VERSION, id, result }); - - const respondError = (id: number | string, message: string, code = -32601) => - writeSerialized({ - jsonrpc: JSON_RPC_VERSION, - id, - error: { code, message }, - }); - - const handleOneLine = (line: string): Effect.Effect => - Effect.gen(function* () { - const parseExit = yield* parseInboundLine(line).pipe(Effect.exit); - if (Exit.isFailure(parseExit)) { - return; - } - if (parseExit.value === null) { - return; - } - const msg = parseExit.value; - - if (msg._tag === "response") { - const map = yield* Ref.get(pending); - const def = map.get(msg.id); - if (!def) return; - const next = new Map(map); - next.delete(msg.id); - yield* Ref.set(pending, next); - if (msg.error) { - yield* Deferred.fail( - def, - new AcpRpcError({ - code: msg.error.code, - ...(msg.error.data !== undefined ? { data: msg.error.data } : {}), - }), - ); - } else { - yield* Deferred.succeed(def, msg.result); - } - return; - } - - if (msg._tag === "notification") { - yield* Queue.offer(notificationQueue, msg); - return; - } - - const handlerMap = yield* Ref.get(handlers); - const handler = handlerMap.get(msg.method); - if (!handler) { - yield* respondError(msg.id, `Method not found: ${msg.method}`); - return; - } - - const exit = yield* Effect.exit(handler(msg.params, msg.id)); - if (Exit.isSuccess(exit)) { - yield* respondResult(msg.id, exit.value); - } else { - const left = Cause.squash(exit.cause); - yield* respondError(msg.id, Schema.is(AcpError)(left) ? left.message : String(left)); - } - }); - - yield* Effect.sync(() => { - child.once("close", (code: number | null, signal: NodeJS.Signals | null) => { - const err = new AcpProcessExitedError({ code, signal }); - void Effect.runPromise( - failAllPending(err).pipe(Effect.tap(() => Queue.shutdown(notificationQueue))), - ).catch(() => { - /* ignore shutdown races */ - }); - }); - }); - - const rl = createInterface({ input: child.stdout, crlfDelay: Infinity }); - yield* Effect.sync(() => { - rl.on("line", (ln: string) => { - void Effect.runPromise(handleOneLine(ln)).catch(() => { - /* parse/handler errors are non-fatal for the transport */ - }); - }); - }); - - const registerHandler = (method: string, handler: AcpServerRequestHandler) => - Ref.update(handlers, (map) => new Map(map).set(method, handler)); - - return { - request: sendRequest, - notify: sendNotify, - registerHandler, - notifications: Stream.fromQueue(notificationQueue), - } satisfies AcpJsonRpcConnection; - }); - -/** - * Spawns an ACP agent process and exposes NDJSON JSON-RPC over stdio. - * Run under `Effect.scoped` so the child is disposed when the scope ends. - */ -export const makeAcpJsonRpcConnection = ( - input: AcpSpawnInput, -): Effect.Effect => - Effect.acquireRelease(spawnAcpChildProcess(input), (child) => - Effect.sync(() => disposeAcpChild(child)), - ).pipe(Effect.flatMap(attachAcpJsonRpcConnection)); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts new file mode 100644 index 00000000000..c7a5d2e0d63 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "vitest"; + +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + extractModelConfigId, + mergeToolCallState, + parsePermissionRequest, + parseSessionModeState, + parseSessionUpdateEvent, +} from "./AcpRuntimeModel.ts"; + +describe("AcpRuntimeModel", () => { + it("parses session mode state from typed ACP session setup responses", () => { + const modeState = parseSessionModeState({ + sessionId: "session-1", + modes: { + currentModeId: " code ", + availableModes: [ + { id: " ask ", name: " Ask ", description: " Request approval " }, + { id: " code ", name: " Code " }, + ], + }, + configOptions: [], + } satisfies EffectAcpSchema.NewSessionResponse); + + expect(modeState).toEqual({ + currentModeId: "code", + availableModes: [ + { id: "ask", name: "Ask", description: "Request approval" }, + { id: "code", name: "Code" }, + ], + }); + }); + + it("extracts the model config id from typed ACP config options", () => { + const modelConfigId = extractModelConfigId({ + sessionId: "session-1", + configOptions: [ + { + id: "approval", + name: "Approval Mode", + category: "permission", + type: "select", + currentValue: "ask", + options: [{ value: "ask", name: "Ask" }], + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "default", + options: [{ value: "default", name: "Auto" }], + }, + ], + } satisfies EffectAcpSchema.NewSessionResponse); + + expect(modelConfigId).toBe("model"); + }); + + it("projects typed ACP tool call updates into runtime events", () => { + const created = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + executable: "bun", + args: ["run", "typecheck"], + }, + content: [ + { + type: "content", + content: { + type: "text", + text: "Running checks", + }, + }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(created.events).toEqual([ + { + _tag: "ToolCallUpdated", + toolCall: { + toolCallId: "tool-1", + itemType: "command_execution", + title: "Terminal", + status: "pending", + command: "bun run typecheck", + detail: "bun run typecheck", + data: { + toolCallId: "tool-1", + kind: "execute", + command: "bun run typecheck", + rawInput: { + executable: "bun", + args: ["run", "typecheck"], + }, + content: [ + { + type: "content", + content: { + type: "text", + text: "Running checks", + }, + }, + ], + }, + }, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + executable: "bun", + args: ["run", "typecheck"], + }, + content: [ + { + type: "content", + content: { + type: "text", + text: "Running checks", + }, + }, + ], + }, + }, + }, + ]); + + const updated = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { exitCode: 0 }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(updated.events).toHaveLength(1); + expect(updated.events[0]?._tag).toBe("ToolCallUpdated"); + const createdEvent = created.events[0]; + const updatedEvent = updated.events[0]; + if (createdEvent?._tag === "ToolCallUpdated" && updatedEvent?._tag === "ToolCallUpdated") { + expect(mergeToolCallState(createdEvent.toolCall, updatedEvent.toolCall)).toMatchObject({ + toolCallId: "tool-1", + status: "completed", + command: "bun run typecheck", + }); + } + }); + + it("projects typed ACP plan and content updates", () => { + const planResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "plan", + entries: [ + { content: " Inspect state ", priority: "high", status: "completed" }, + { content: "", priority: "medium", status: "in_progress" }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(planResult.events).toEqual([ + { + _tag: "PlanUpdated", + payload: { + plan: [ + { step: "Inspect state", status: "completed" }, + { step: "Step 2", status: "inProgress" }, + ], + }, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "plan", + entries: [ + { content: " Inspect state ", priority: "high", status: "completed" }, + { content: "", priority: "medium", status: "in_progress" }, + ], + }, + }, + }, + ]); + + const contentResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello from acp", + }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(contentResult.events).toEqual([ + { + _tag: "ContentDelta", + text: "hello from acp", + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello from acp", + }, + }, + }, + }, + ]); + }); + + it("keeps permission request parsing compatible with loose extension payloads", () => { + const request = parsePermissionRequest({ + sessionId: "session-1", + options: [ + { + optionId: "allow-once", + name: "Allow once", + kind: "allow_once", + }, + ], + toolCall: { + toolCallId: "tool-1", + title: "`cat package.json`", + kind: "execute", + status: "pending", + content: [ + { + type: "content", + content: { + type: "text", + text: "Not in allowlist", + }, + }, + ], + }, + }); + + expect(request).toMatchObject({ + requestType: "exec_command_approval", + detail: "cat package.json", + toolCall: { + toolCallId: "tool-1", + itemType: "command_execution", + status: "pending", + command: "cat package.json", + }, + }); + }); +}); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts new file mode 100644 index 00000000000..73720df7e73 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -0,0 +1,445 @@ +import type { ToolLifecycleItemType } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export interface AcpSessionMode { + readonly id: string; + readonly name: string; + readonly description?: string; +} + +export interface AcpSessionModeState { + readonly currentModeId: string; + readonly availableModes: ReadonlyArray; +} + +export interface AcpToolCallState { + readonly toolCallId: string; + readonly itemType: ToolLifecycleItemType; + readonly title?: string; + readonly status?: "pending" | "inProgress" | "completed" | "failed"; + readonly command?: string; + readonly detail?: string; + readonly data: Record; +} + +export interface AcpPlanUpdate { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; +} + +export interface AcpPermissionRequest { + readonly requestType: + | "exec_command_approval" + | "file_read_approval" + | "file_change_approval" + | "unknown"; + readonly detail?: string; + readonly toolCall?: AcpToolCallState; +} + +export type AcpParsedSessionEvent = + | { + readonly _tag: "ModeChanged"; + readonly modeId: string; + } + | { + readonly _tag: "PlanUpdated"; + readonly payload: AcpPlanUpdate; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ToolCallUpdated"; + readonly toolCall: AcpToolCallState; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ContentDelta"; + readonly text: string; + readonly rawPayload: unknown; + }; + +type AcpSessionSetupResponse = + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + +type AcpToolCallUpdate = Extract< + EffectAcpSchema.SessionNotification["update"], + { readonly sessionUpdate: "tool_call" | "tool_call_update" } +>; + +export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined { + const configOptions = sessionResponse.configOptions; + if (!configOptions) return undefined; + for (const opt of configOptions) { + if (opt.category === "model" && opt.id.trim().length > 0) { + return opt.id.trim(); + } + } + return undefined; +} + +export function parseSessionModeState( + sessionResponse: AcpSessionSetupResponse, +): AcpSessionModeState | undefined { + const modes = sessionResponse.modes; + if (!modes) return undefined; + const currentModeId = modes.currentModeId.trim(); + if (!currentModeId) { + return undefined; + } + const availableModes = modes.availableModes + .map((mode) => { + const id = mode.id.trim(); + const name = mode.name.trim(); + if (!id || !name) { + return undefined; + } + const description = mode.description?.trim() || undefined; + return description !== undefined + ? ({ id, name, description } satisfies AcpSessionMode) + : ({ id, name } satisfies AcpSessionMode); + }) + .filter((mode): mode is AcpSessionMode => mode !== undefined); + if (availableModes.length === 0) { + return undefined; + } + return { + currentModeId, + availableModes, + }; +} + +function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" { + switch (raw) { + case "completed": + return "completed"; + case "in_progress": + case "inProgress": + return "inProgress"; + default: + return "pending"; + } +} + +function normalizeToolCallStatus( + raw: unknown, + fallback?: "pending" | "inProgress" | "completed" | "failed", +): "pending" | "inProgress" | "completed" | "failed" | undefined { + switch (raw) { + case "pending": + return "pending"; + case "in_progress": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return fallback; + } +} + +function normalizeCommandValue(value: unknown): string | undefined { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (!Array.isArray(value)) { + return undefined; + } + const parts = value + .map((entry) => (typeof entry === "string" && entry.trim().length > 0 ? entry.trim() : null)) + .filter((entry): entry is string => entry !== null); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function extractCommandFromTitle(title: string | undefined): string | undefined { + if (!title) { + return undefined; + } + const match = /`([^`]+)`/.exec(title); + return match?.[1]?.trim() || undefined; +} + +function extractToolCallCommand(rawInput: unknown, title: string | undefined): string | undefined { + if (isRecord(rawInput)) { + const directCommand = normalizeCommandValue(rawInput.command); + if (directCommand) { + return directCommand; + } + const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : ""; + const args = normalizeCommandValue(rawInput.args); + if (executable && args) { + return `${executable} ${args}`; + } + if (executable) { + return executable; + } + } + return extractCommandFromTitle(title); +} + +function extractTextContentFromToolCallContent( + content: ReadonlyArray | null | undefined, +): string | undefined { + if (!content) return undefined; + const chunks = content + .map((entry) => { + if (entry.type !== "content") { + return undefined; + } + const nestedContent = entry.content; + if (nestedContent.type !== "text") { + return undefined; + } + return nestedContent.text.trim().length > 0 ? nestedContent.text.trim() : undefined; + }) + .filter((entry): entry is string => entry !== undefined); + return chunks.length > 0 ? chunks.join("\n") : undefined; +} + +function toolLifecycleItemTypeFromKind(kind: unknown): ToolLifecycleItemType { + switch (kind) { + case "execute": + return "command_execution"; + case "edit": + case "delete": + case "move": + return "file_change"; + case "search": + case "fetch": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + +function requestTypeFromToolKind( + kind: unknown, +): "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" { + switch (kind) { + case "execute": + return "exec_command_approval"; + case "read": + return "file_read_approval"; + case "edit": + case "delete": + case "move": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function makeToolCallState( + input: { + readonly toolCallId: string; + readonly title?: string | null | undefined; + readonly kind?: EffectAcpSchema.ToolKind | null | undefined; + readonly status?: EffectAcpSchema.ToolCallStatus | null | undefined; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; + readonly content?: ReadonlyArray | null | undefined; + readonly locations?: ReadonlyArray | null | undefined; + }, + options?: { + readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; + }, +): AcpToolCallState | undefined { + const toolCallId = input.toolCallId.trim(); + if (!toolCallId) { + return undefined; + } + const title = input.title?.trim() || undefined; + const command = extractToolCallCommand(input.rawInput, title); + const textContent = extractTextContentFromToolCallContent(input.content); + const normalizedTitle = + title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" + ? title + : undefined; + const detail = command ?? normalizedTitle ?? textContent; + const data: Record = { toolCallId }; + if (input.kind) { + data.kind = input.kind; + } + if (command) { + data.command = command; + } + if (input.rawInput !== undefined) { + data.rawInput = input.rawInput; + } + if (input.rawOutput !== undefined) { + data.rawOutput = input.rawOutput; + } + if (input.content !== undefined) { + data.content = input.content; + } + if (input.locations !== undefined) { + data.locations = input.locations; + } + const status = normalizeToolCallStatus(input.status, options?.fallbackStatus); + return { + toolCallId, + itemType: toolLifecycleItemTypeFromKind(input.kind), + ...(title ? { title } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(detail ? { detail } : {}), + data, + }; +} + +function parseTypedToolCallState( + event: AcpToolCallUpdate, + options?: { + readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; + }, +): AcpToolCallState | undefined { + return makeToolCallState( + { + toolCallId: event.toolCallId, + title: event.title, + kind: event.kind, + status: event.status, + rawInput: event.rawInput, + rawOutput: event.rawOutput, + content: event.content, + locations: event.locations, + }, + options, + ); +} + +export function mergeToolCallState( + previous: AcpToolCallState | undefined, + next: AcpToolCallState, +): AcpToolCallState { + const nextKind = typeof next.data.kind === "string" ? next.data.kind : undefined; + const title = next.title ?? previous?.title; + const status = next.status ?? previous?.status; + const command = next.command ?? previous?.command; + const detail = next.detail ?? previous?.detail; + return { + toolCallId: next.toolCallId, + itemType: nextKind !== undefined ? next.itemType : (previous?.itemType ?? next.itemType), + ...(title ? { title } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(detail ? { detail } : {}), + data: { + ...previous?.data, + ...next.data, + }, + }; +} + +export function parsePermissionRequest( + params: EffectAcpSchema.RequestPermissionRequest, +): AcpPermissionRequest { + const toolCall = makeToolCallState( + { + toolCallId: params.toolCall.toolCallId, + title: params.toolCall.title, + kind: params.toolCall.kind, + status: params.toolCall.status, + rawInput: params.toolCall.rawInput, + rawOutput: params.toolCall.rawOutput, + content: params.toolCall.content, + locations: params.toolCall.locations, + }, + { fallbackStatus: "pending" }, + ); + const requestType = requestTypeFromToolKind(params.toolCall.kind); + const detail = + toolCall?.command ?? + toolCall?.title ?? + toolCall?.detail ?? + (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); + return { + requestType, + ...(detail ? { detail } : {}), + ...(toolCall ? { toolCall } : {}), + }; +} + +export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { + readonly modeId?: string; + readonly events: ReadonlyArray; +} { + const upd = params.update; + const events: Array = []; + let modeId: string | undefined; + + switch (upd.sessionUpdate) { + case "current_mode_update": { + modeId = upd.currentModeId; + events.push({ + _tag: "ModeChanged", + modeId, + }); + break; + } + case "plan": { + const plan = upd.entries.map((entry, index) => ({ + step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, + status: normalizePlanStepStatus(entry.status), + })); + if (plan.length > 0) { + events.push({ + _tag: "PlanUpdated", + payload: { + plan, + }, + rawPayload: params, + }); + } + break; + } + case "tool_call": { + const toolCall = parseTypedToolCallState(upd, { + fallbackStatus: "pending", + }); + if (toolCall) { + events.push({ + _tag: "ToolCallUpdated", + toolCall, + rawPayload: params, + }); + } + break; + } + case "tool_call_update": { + const toolCall = parseTypedToolCallState(upd); + if (toolCall) { + events.push({ + _tag: "ToolCallUpdated", + toolCall, + rawPayload: params, + }); + } + break; + } + case "agent_message_chunk": { + if (upd.content.type === "text" && upd.content.text.length > 0) { + events.push({ + _tag: "ContentDelta", + text: upd.content.text, + rawPayload: params, + }); + } + break; + } + default: + break; + } + + return { ...(modeId !== undefined ? { modeId } : {}), events }; +} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts new file mode 100644 index 00000000000..d0540093327 --- /dev/null +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -0,0 +1,265 @@ +import { Effect, Exit, Queue, Ref, Scope, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as EffectAcpClient from "effect-acp/client"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + extractModelConfigId, + mergeToolCallState, + parseSessionModeState, + parseSessionUpdateEvent, + type AcpParsedSessionEvent, + type AcpSessionModeState, + type AcpToolCallState, +} from "./AcpRuntimeModel.ts"; + +export interface AcpSpawnInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string; + readonly env?: Readonly>; +} + +export interface AcpSessionRuntimeOptions { + readonly spawn: AcpSpawnInput; + readonly cwd: string; + readonly resumeSessionId?: string; + readonly clientInfo: { + readonly name: string; + readonly version: string; + }; + readonly authMethodId?: string; + readonly handlers?: Omit; +} + +export interface AcpSessionRuntime { + readonly sessionId: string; + readonly initializeResult: EffectAcpSchema.InitializeResponse; + readonly sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + readonly modelConfigId: string | undefined; + readonly events: Stream.Stream; + readonly getModeState: Effect.Effect; + readonly prompt: ( + payload: Omit, + ) => Effect.Effect; + readonly cancel: Effect.Effect; + readonly setMode: ( + modeId: string, + ) => Effect.Effect; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; + readonly request: ( + method: string, + payload: unknown, + ) => Effect.Effect; + readonly notify: ( + method: string, + payload: unknown, + ) => Effect.Effect; + readonly close: Effect.Effect; +} + +export const makeAcpSessionRuntime = ( + options: AcpSessionRuntimeOptions, +): Effect.Effect< + AcpSessionRuntime, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeScope = yield* Scope.make("sequential"); + const eventQueue = yield* Queue.unbounded(); + const modeStateRef = yield* Ref.make(undefined); + const toolCallsRef = yield* Ref.make(new Map()); + + const child = yield* spawner + .spawn( + ChildProcess.make(options.spawn.command, [...options.spawn.args], { + ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), + ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}), + shell: process.platform === "win32", + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpSpawnError({ + command: options.spawn.command, + cause, + }), + ), + ); + + const client = yield* EffectAcpClient.fromChildProcess(child, { + handlers: { + ...options.handlers, + sessionUpdate: (notification) => + handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + toolCallsRef, + params: notification, + }), + }, + }).pipe(Effect.provideService(Scope.Scope, runtimeScope)); + + const initializeResult = yield* client.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: options.clientInfo, + }); + + yield* client.authenticate({ + methodId: options.authMethodId ?? "cursor_login", + }); + + let sessionId: string; + let sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + if (options.resumeSessionId) { + const resumed = yield* client + .loadSession({ + sessionId: options.resumeSessionId, + cwd: options.cwd, + mcpServers: [], + }) + .pipe(Effect.exit); + if (Exit.isSuccess(resumed)) { + sessionId = options.resumeSessionId; + sessionSetupResult = resumed.value; + } else { + const created = yield* client.createSession({ + cwd: options.cwd, + mcpServers: [], + }); + sessionId = created.sessionId; + sessionSetupResult = created; + } + } else { + const created = yield* client.createSession({ + cwd: options.cwd, + mcpServers: [], + }); + sessionId = created.sessionId; + sessionSetupResult = created; + } + + yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); + + const close = Scope.close(runtimeScope, Exit.void).pipe(Effect.asVoid); + + return { + sessionId, + initializeResult, + sessionSetupResult, + modelConfigId: extractModelConfigId(sessionSetupResult), + events: Stream.fromQueue(eventQueue), + getModeState: Ref.get(modeStateRef), + prompt: (payload) => + client.prompt({ + sessionId, + ...payload, + }), + cancel: client.cancel({ sessionId }), + setMode: (modeId) => + client.setSessionMode({ + sessionId, + modeId, + }), + setConfigOption: (configId, value) => + client.setSessionConfigOption( + typeof value === "boolean" + ? ({ + sessionId, + configId, + type: "boolean", + value, + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) + : ({ + sessionId, + configId, + value: String(value), + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest), + ), + setModel: (model) => + client + .setSessionConfigOption({ + sessionId, + configId: extractModelConfigId(sessionSetupResult) ?? "model", + value: model, + }) + .pipe(Effect.asVoid), + request: client.extRequest, + notify: client.extNotification, + close, + } satisfies AcpSessionRuntime; + }); + +const handleSessionUpdate = ({ + queue, + modeStateRef, + toolCallsRef, + params, +}: { + readonly queue: Queue.Queue; + readonly modeStateRef: Ref.Ref; + readonly toolCallsRef: Ref.Ref>; + readonly params: EffectAcpSchema.SessionNotification; +}): Effect.Effect => + Effect.gen(function* () { + const parsed = parseSessionUpdateEvent(params); + if (parsed.modeId) { + yield* Ref.update(modeStateRef, (current) => + current === undefined ? current : updateModeState(current, parsed.modeId!), + ); + } + for (const event of parsed.events) { + if (event._tag === "ToolCallUpdated") { + const merged = yield* Ref.modify(toolCallsRef, (current) => { + const previous = current.get(event.toolCall.toolCallId); + const nextToolCall = mergeToolCallState(previous, event.toolCall); + const next = new Map(current); + if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { + next.delete(nextToolCall.toolCallId); + } else { + next.set(nextToolCall.toolCallId, nextToolCall); + } + return [nextToolCall, next] as const; + }); + yield* Queue.offer(queue, { + _tag: "ToolCallUpdated", + toolCall: merged, + rawPayload: event.rawPayload, + }); + continue; + } + yield* Queue.offer(queue, event); + } + }).pipe(Effect.mapError(EffectAcpErrors.normalizeAcpError)); + +function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState { + const normalized = nextModeId.trim(); + if (!normalized) { + return modeState; + } + return modeState.availableModes.some((mode) => mode.id === normalized) + ? { + ...modeState, + currentModeId: normalized, + } + : modeState; +} diff --git a/apps/server/src/provider/acp/AcpTypes.ts b/apps/server/src/provider/acp/AcpTypes.ts deleted file mode 100644 index 6917c70bc43..00000000000 --- a/apps/server/src/provider/acp/AcpTypes.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Effect, Option, Schema, SchemaIssue, SchemaTransformation } from "effect"; - -import type { AcpError } from "./AcpErrors.ts"; - -/** JSON-RPC 2.0 error object on the wire. */ -export const JsonRpcErrorPayload = Schema.Struct({ - code: Schema.Number, - message: Schema.String, - data: Schema.optional(Schema.Unknown), -}); - -/** Parsed JSON object from one NDJSON line before JSON-RPC classification. */ -export const JsonRpcInboundWire = Schema.Struct({ - jsonrpc: Schema.optional(Schema.String), - id: Schema.optional(Schema.Union([Schema.String, Schema.Number])), - method: Schema.optional(Schema.String), - params: Schema.optional(Schema.Unknown), - result: Schema.optional(Schema.Unknown), - error: Schema.optional(JsonRpcErrorPayload), -}); - -export const AcpInboundResponse = Schema.TaggedStruct("response", { - id: Schema.Union([Schema.String, Schema.Number]), - result: Schema.optional(Schema.Unknown), - error: Schema.optional(JsonRpcErrorPayload), -}); - -export const AcpInboundRequest = Schema.TaggedStruct("request", { - id: Schema.Union([Schema.String, Schema.Number]), - method: Schema.String, - params: Schema.optional(Schema.Unknown), -}); - -export const AcpInboundNotification = Schema.TaggedStruct("notification", { - method: Schema.String, - params: Schema.optional(Schema.Unknown), -}); - -/** - * Inbound JSON-RPC messages from the ACP agent (stdout), after line framing. - */ -export const AcpInboundMessage = Schema.Union([ - AcpInboundResponse, - AcpInboundRequest, - AcpInboundNotification, -]); - -export type AcpInboundMessage = typeof AcpInboundMessage.Type; - -const jsonRpcWireToInbound = SchemaTransformation.transformOrFail({ - decode: (parsed: typeof JsonRpcInboundWire.Type) => { - const id = parsed.id; - const method = parsed.method; - const hasId = id !== undefined && id !== null; - const hasMethod = typeof method === "string"; - - if (hasId && (parsed.result !== undefined || parsed.error !== undefined)) { - const err = parsed.error; - const rpcError = - err !== undefined - ? { - code: err.code, - message: err.message, - ...(err.data !== undefined ? { data: err.data } : {}), - } - : undefined; - return Effect.succeed( - AcpInboundResponse.makeUnsafe({ - id, - ...(parsed.result !== undefined ? { result: parsed.result } : {}), - ...(rpcError ? { error: rpcError } : {}), - }), - ); - } - - if (hasMethod && hasId) { - return Effect.succeed( - AcpInboundRequest.makeUnsafe({ - id, - method, - ...(parsed.params !== undefined ? { params: parsed.params } : {}), - }), - ); - } - - if (hasMethod && !hasId) { - return Effect.succeed( - AcpInboundNotification.makeUnsafe({ - method, - ...(parsed.params !== undefined ? { params: parsed.params } : {}), - }), - ); - } - - return Effect.fail( - new SchemaIssue.InvalidValue(Option.some(parsed), { - message: "Unrecognized JSON-RPC inbound message shape", - }), - ); - }, - - encode: (msg: AcpInboundMessage) => { - if (msg._tag === "response") { - return Effect.succeed({ - jsonrpc: "2.0" as const, - id: msg.id, - ...(msg.result !== undefined ? { result: msg.result } : {}), - ...(msg.error !== undefined ? { error: msg.error } : {}), - }); - } - if (msg._tag === "request") { - return Effect.succeed({ - jsonrpc: "2.0" as const, - id: msg.id, - method: msg.method, - ...(msg.params !== undefined ? { params: msg.params } : {}), - }); - } - return Effect.succeed({ - jsonrpc: "2.0" as const, - method: msg.method, - ...(msg.params !== undefined ? { params: msg.params } : {}), - }); - }, -}); - -const jsonRpcWireDecodedToInbound = JsonRpcInboundWire.pipe( - Schema.decodeTo(Schema.toType(AcpInboundMessage), jsonRpcWireToInbound), -); - -/** Decode one NDJSON line (JSON string) to a classified inbound message. */ -export const AcpInboundFromJsonLine = Schema.fromJsonString(jsonRpcWireDecodedToInbound); - -export const decodeAcpInboundFromJsonLine = Schema.decodeEffect(AcpInboundFromJsonLine); - -export interface AcpSpawnInput { - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd?: string; - /** Merged with `process.env` for the child. */ - readonly env?: Readonly>; -} - -export type AcpServerRequestHandler = ( - params: unknown, - requestId: number | string, -) => Effect.Effect; diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index deb86f99c04..d577fc56cb6 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -6,125 +6,90 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect } from "effect"; import { describe, expect } from "vitest"; +import type * as EffectAcpSchema from "effect-acp/schema"; -import { makeAcpJsonRpcConnection } from "./AcpJsonRpcConnection.ts"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +import { makeAcpSessionRuntime } from "./AcpSessionRuntime.ts"; describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => { it.effect("initialize and authenticate against real agent acp", () => Effect.gen(function* () { - const conn = yield* makeAcpJsonRpcConnection({ - command: "agent", - args: ["acp"], - cwd: process.cwd(), - }); - - const init = yield* conn.request("initialize", { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, + const runtime = yield* makeAcpSessionRuntime({ + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), }, + cwd: process.cwd(), clientInfo: { name: "t3-probe", version: "0.0.0" }, }); - expect(init).toBeDefined(); - - yield* conn.request("authenticate", { methodId: "cursor_login" }); + expect(runtime.initializeResult).toBeDefined(); + yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); it.effect("session/new returns configOptions with a model selector", () => Effect.gen(function* () { - const conn = yield* makeAcpJsonRpcConnection({ - command: "agent", - args: ["acp"], + const runtime = yield* makeAcpSessionRuntime({ + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, cwd: process.cwd(), - }); - - yield* conn.request("initialize", { - protocolVersion: 1, - clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, clientInfo: { name: "t3-probe", version: "0.0.0" }, }); - yield* conn.request("authenticate", { methodId: "cursor_login" }); - - const result = yield* conn.request("session/new", { - cwd: process.cwd(), - mcpServers: [], - }); + const result = runtime.sessionSetupResult; console.log("session/new result:", JSON.stringify(result, null, 2)); - expect(isRecord(result)).toBe(true); - const r = result as Record; - expect(typeof r.sessionId).toBe("string"); + expect(typeof runtime.sessionId).toBe("string"); - const configOptions = r.configOptions; + const configOptions = result.configOptions; console.log("session/new configOptions:", JSON.stringify(configOptions, null, 2)); if (Array.isArray(configOptions)) { - const modelConfig = configOptions.find( - (opt: unknown) => isRecord(opt) && opt.category === "model", - ); + const modelConfig = configOptions.find((opt) => opt.category === "model"); console.log("Model config option:", JSON.stringify(modelConfig, null, 2)); expect(modelConfig).toBeDefined(); - expect(isRecord(modelConfig) && typeof modelConfig.id === "string").toBe(true); + expect(typeof modelConfig?.id).toBe("string"); } + yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); it.effect("session/set_config_option switches the model in-session", () => Effect.gen(function* () { - const conn = yield* makeAcpJsonRpcConnection({ - command: "agent", - args: ["acp"], + const runtime = yield* makeAcpSessionRuntime({ + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, cwd: process.cwd(), - }); - - yield* conn.request("initialize", { - protocolVersion: 1, - clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, clientInfo: { name: "t3-probe", version: "0.0.0" }, }); - yield* conn.request("authenticate", { methodId: "cursor_login" }); - - const newResult = (yield* conn.request("session/new", { - cwd: process.cwd(), - mcpServers: [], - })) as Record; - const sessionId = newResult.sessionId as string; + const newResult = runtime.sessionSetupResult; const configOptions = newResult.configOptions; let modelConfigId = "model"; if (Array.isArray(configOptions)) { - const modelConfig = configOptions.find( - (opt: unknown) => isRecord(opt) && opt.category === "model", - ); - if (isRecord(modelConfig) && typeof modelConfig.id === "string") { + const modelConfig = configOptions.find((opt) => opt.category === "model"); + if (typeof modelConfig?.id === "string") { modelConfigId = modelConfig.id; } } - const setResult = yield* conn.request("session/set_config_option", { - sessionId, - configId: modelConfigId, - value: "composer-2", - }); + const setResult: EffectAcpSchema.SetSessionConfigOptionResponse = + yield* runtime.setConfigOption(modelConfigId, "composer-2"); console.log("session/set_config_option result:", JSON.stringify(setResult, null, 2)); - expect(isRecord(setResult)).toBe(true); - const sr = setResult as Record; - if (Array.isArray(sr.configOptions)) { - const modelConfig = sr.configOptions.find( - (opt: unknown) => isRecord(opt) && opt.category === "model", - ); - if (isRecord(modelConfig)) { + if (Array.isArray(setResult.configOptions)) { + const modelConfig = setResult.configOptions.find((opt) => opt.category === "model"); + if (modelConfig?.type === "select") { expect(modelConfig.currentValue).toBe("composer-2"); } } + yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/server/src/provider/acp/CursorAcpExtension.test.ts b/apps/server/src/provider/acp/CursorAcpExtension.test.ts new file mode 100644 index 00000000000..331c389a5c1 --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpExtension.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { + extractAskQuestions, + extractPlanMarkdown, + extractTodosAsPlan, +} from "./CursorAcpExtension.ts"; + +describe("CursorAcpExtension", () => { + it("extracts ask-question prompts from the real Cursor ACP payload shape", () => { + const questions = extractAskQuestions({ + toolCallId: "ask-1", + title: "Need input", + questions: [ + { + id: "language", + prompt: "Which language should I use?", + options: [ + { id: "ts", label: "TypeScript" }, + { id: "rs", label: "Rust" }, + ], + allowMultiple: false, + }, + ], + }); + + expect(questions).toEqual([ + { + id: "language", + header: "Question", + question: "Which language should I use?", + options: [ + { label: "TypeScript", description: "TypeScript" }, + { label: "Rust", description: "Rust" }, + ], + }, + ]); + }); + + it("extracts plan markdown from the real Cursor create-plan payload shape", () => { + const planMarkdown = extractPlanMarkdown({ + toolCallId: "plan-1", + name: "Refactor parser", + overview: "Tighten ACP parsing", + plan: "# Plan\n\n1. Add schemas\n2. Remove casts", + todos: [ + { id: "t1", content: "Add schemas", status: "in_progress" }, + { id: "t2", content: "Remove casts", status: "pending" }, + ], + isProject: false, + }); + + expect(planMarkdown).toBe("# Plan\n\n1. Add schemas\n2. Remove casts"); + }); + + it("projects todo updates into a plan shape", () => { + expect( + extractTodosAsPlan({ + todos: [ + { content: "Inspect state", status: "completed" }, + { title: "Apply fix", status: "in_progress" }, + {}, + ], + }), + ).toEqual({ + plan: [ + { step: "Inspect state", status: "completed" }, + { step: "Apply fix", status: "inProgress" }, + { step: "Step 3", status: "pending" }, + ], + }); + }); +}); diff --git a/apps/server/src/provider/acp/CursorAcpExtension.ts b/apps/server/src/provider/acp/CursorAcpExtension.ts new file mode 100644 index 00000000000..abc78a1c8af --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpExtension.ts @@ -0,0 +1,116 @@ +import type { UserInputQuestion } from "@t3tools/contracts"; +import { Schema } from "effect"; + +const CursorAskQuestionOption = Schema.Struct({ + id: Schema.String, + label: Schema.String, +}); + +const CursorAskQuestion = Schema.Struct({ + id: Schema.String, + prompt: Schema.String, + options: Schema.Array(CursorAskQuestionOption), + allowMultiple: Schema.Boolean, +}); + +export const CursorAskQuestionRequest = Schema.Struct({ + toolCallId: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + questions: Schema.Array(CursorAskQuestion), +}); + +const CursorTodoStatus = Schema.Union([ + Schema.Literal("pending"), + Schema.Literal("in_progress"), + Schema.Literal("completed"), + Schema.Literal("cancelled"), +]); + +const CursorTodo = Schema.Struct({ + id: Schema.optional(Schema.String), + content: Schema.optional(Schema.String), + status: CursorTodoStatus, +}); + +const CursorPlanPhase = Schema.Struct({ + name: Schema.optional(Schema.String), + todos: Schema.Array(CursorTodo), +}); + +export const CursorCreatePlanRequest = Schema.Struct({ + toolCallId: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + overview: Schema.optional(Schema.String), + plan: Schema.String, + todos: Schema.Array(CursorTodo), + isProject: Schema.optional(Schema.Boolean), + phases: Schema.optional(Schema.Array(CursorPlanPhase)), +}); + +export const CursorUpdateTodosRequest = Schema.Unknown; + +export function extractAskQuestions( + params: typeof CursorAskQuestionRequest.Type, +): ReadonlyArray { + return params.questions.map((question) => ({ + id: question.id, + header: "Question", + question: question.prompt, + multiSelect: question.allowMultiple, + options: + question.options.length > 0 + ? question.options.map((option) => ({ + label: option.label, + description: option.label, + })) + : [{ label: "OK", description: "Continue" }], + })); +} + +export function extractPlanMarkdown(params: typeof CursorCreatePlanRequest.Type): string { + return params.plan || "# Plan\n\n(Cursor did not supply plan text.)"; +} + +export function extractTodosAsPlan(params: typeof CursorUpdateTodosRequest.Type): { + readonly explanation?: string; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; +} { + if (typeof params !== "object" || params === null) { + return { plan: [] }; + } + const record = params as { + readonly todos?: ReadonlyArray<{ + readonly content?: string; + readonly title?: string; + readonly status?: string; + }>; + readonly items?: ReadonlyArray<{ + readonly content?: string; + readonly title?: string; + readonly status?: string; + }>; + }; + const todos = record.todos ?? record.items; + if (!todos) { + return { plan: [] }; + } + const plan = todos.map((t, i) => { + const step = + typeof t?.content === "string" + ? t.content + : typeof t?.title === "string" + ? t.title + : `Step ${i + 1}`; + const status: "pending" | "inProgress" | "completed" = + t?.status === "completed" + ? "completed" + : t?.status === "in_progress" || t?.status === "inProgress" + ? "inProgress" + : "pending"; + return { step, status }; + }); + return { plan }; +} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 6537511e730..59f5bca61fb 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -81,7 +81,7 @@ export function makeServerProviderLayer(): Layer.Layer< ); const cursorAdapterLayer = makeCursorAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, - ); + ).pipe(Layer.provideMerge(NodeServices.layer)); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), diff --git a/bun.lock b/bun.lock index 308a815e34c..1f3457f045b 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", + "effect-acp": "workspace:*", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0", @@ -184,18 +185,21 @@ }, }, }, + "patchedDependencies": { + "effect@4.0.0-beta.41": "patches/effect@4.0.0-beta.41.patch", + }, "overrides": { "vite": "^8.0.0", }, "catalog": { "@effect/language-service": "0.75.1", - "@effect/openapi-generator": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/openapi-generator@8881a9b", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", + "@effect/openapi-generator": "4.0.0-beta.41", + "@effect/platform-node": "4.0.0-beta.41", + "@effect/sql-sqlite-bun": "4.0.0-beta.41", + "@effect/vitest": "4.0.0-beta.41", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", + "effect": "4.0.0-beta.41", "tsdown": "^0.20.3", "typescript": "^5.7.3", "vitest": "^4.0.0", @@ -283,15 +287,15 @@ "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], - "@effect/openapi-generator": ["@effect/openapi-generator@https://pkg.pr.new/Effect-TS/effect-smol/@effect/openapi-generator@8881a9b", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.25", "effect": "^4.0.0-beta.25" }, "bin": { "openapigen": "./dist/bin.js" } }], + "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.41", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.41", "effect": "^4.0.0-beta.41" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-7eTTSCFjSyCMaG76fl0YiFEjfdTWaUiTS2mJ4RM0Q7jUfrYyw3rwosPqVJ8N9zWmZSnoUyD+qAD/ZK8hW970hw=="], - "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25", "ioredis": "^5.7.0" } }], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.41", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.41", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.41", "ioredis": "^5.7.0" } }, "sha512-lgsWfvJfxwg7xXT5rK3xcPyAFPAYXXJ0u/6yCu2suOMhdR5w2W6oA2L2pIQaqap8qR8uEVB5pHq6dqVfdRP7Nw=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25" } }], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.41", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.41" } }, "sha512-YzKWz1G8YofCrQ0Thxymlk71CM3q7R1vzO2vtnb7KSHkTJrvrQB8FbZaM8mEddYOoamR25f46lidFKErmmotKg=="], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25" } }], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.41", "", { "peerDependencies": { "effect": "^4.0.0-beta.41" } }, "sha512-z3OvuTKE7GYIMZefKrTHNNuPaXxVpcSZmEAJd8ITap8wHVEEBs4WT+p/CHDae4dlJc6HJeWGSHxQuPVmdC16bA=="], - "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25", "vitest": "^3.0.0 || ^4.0.0" } }], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.41", "", { "peerDependencies": { "effect": "^4.0.0-beta.41", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-VCwK2Sj1gbCaC7eoipS/pmty1plN08nNKF6z0sn9S3q+6OLCbeJ3+2qKzFQCnpdkvud45HNK3Z9kVCZew6kKhA=="], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1035,7 +1039,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], + "effect": ["effect@4.0.0-beta.41", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-kBjbmo2qqXbOgrvZcPgVdgsOOWcGPYwRcvGO3aGPWJhpXxDFNfgtwqtU6asMq2M7LSFRx1SA+3BzJm7FDqtxew=="], "effect-acp": ["effect-acp@workspace:packages/effect-acp"], @@ -1925,18 +1929,6 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@effect/openapi-generator/@effect/platform-node": ["@effect/platform-node@4.0.0-beta.41", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.41", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.41", "ioredis": "^5.7.0" } }, "sha512-lgsWfvJfxwg7xXT5rK3xcPyAFPAYXXJ0u/6yCu2suOMhdR5w2W6oA2L2pIQaqap8qR8uEVB5pHq6dqVfdRP7Nw=="], - - "@effect/openapi-generator/effect": ["effect@4.0.0-beta.41", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-kBjbmo2qqXbOgrvZcPgVdgsOOWcGPYwRcvGO3aGPWJhpXxDFNfgtwqtU6asMq2M7LSFRx1SA+3BzJm7FDqtxew=="], - - "@effect/platform-node/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - - "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - - "@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - - "@effect/vitest/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2027,8 +2019,6 @@ "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], - "@effect/openapi-generator/@effect/platform-node/@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.41", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.41" } }, "sha512-YzKWz1G8YofCrQ0Thxymlk71CM3q7R1vzO2vtnb7KSHkTJrvrQB8FbZaM8mEddYOoamR25f46lidFKErmmotKg=="], - "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], diff --git a/package.json b/package.json index 4d602ed10c9..45f0c14b731 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "scripts" ], "catalog": { - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", - "@effect/openapi-generator": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/openapi-generator@8881a9b", + "effect": "4.0.0-beta.41", + "@effect/platform-node": "4.0.0-beta.41", + "@effect/sql-sqlite-bun": "4.0.0-beta.41", + "@effect/vitest": "4.0.0-beta.41", + "@effect/openapi-generator": "4.0.0-beta.41", "@effect/language-service": "0.75.1", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", @@ -70,5 +70,8 @@ "workerDirectory": [ "apps/web/public" ] + }, + "patchedDependencies": { + "effect@4.0.0-beta.41": "patches/effect@4.0.0-beta.41.patch" } } diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index f4fcf41cfb0..fe8869d5894 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -17,15 +17,15 @@ import { ProviderKind } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); -const RuntimeEventRawSource = Schema.Literals([ - "codex.app-server.notification", - "codex.app-server.request", - "codex.eventmsg", - "claude.sdk.message", - "claude.sdk.permission", - "codex.sdk.thread-event", - "acp.jsonrpc", - "acp.cursor.extension", +const RuntimeEventRawSource = Schema.Union([ + Schema.Literal("codex.app-server.notification"), + Schema.Literal("codex.app-server.request"), + Schema.Literal("codex.eventmsg"), + Schema.Literal("claude.sdk.message"), + Schema.Literal("claude.sdk.permission"), + Schema.Literal("codex.sdk.thread-event"), + Schema.Literal("acp.jsonrpc"), + Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index c059c57cc3d..117c358a2d6 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -4,74 +4,36 @@ "type": "module", "exports": { "./client": { - "import": { - "types": "./src/client.d.mts", - "default": "./src/client.mjs" - }, - "require": { - "types": "./src/client.d.cts", - "default": "./src/client.cjs" - } + "types": "./src/client.ts", + "import": "./src/client.ts" }, "./server": { - "import": { - "types": "./src/server.d.mts", - "default": "./src/server.mjs" - }, - "require": { - "types": "./src/server.d.cts", - "default": "./src/server.cjs" - } + "types": "./src/server.ts", + "import": "./src/server.ts" }, "./schema": { - "import": { - "types": "./src/_generated/schema.gen.d.mts", - "default": "./src/_generated/schema.gen.mjs" - }, - "require": { - "types": "./src/_generated/schema.gen.d.cts", - "default": "./src/_generated/schema.gen.cjs" - } + "types": "./src/schema.ts", + "import": "./src/schema.ts" }, "./rpc": { - "import": { - "types": "./src/rpc.d.mts", - "default": "./src/rpc.mjs" - }, - "require": { - "types": "./src/rpc.d.cts", - "default": "./src/rpc.cjs" - } + "types": "./src/rpc.ts", + "import": "./src/rpc.ts" }, "./protocol": { - "import": { - "types": "./src/protocol.d.mts", - "default": "./src/protocol.mjs" - }, - "require": { - "types": "./src/protocol.d.cts", - "default": "./src/protocol.cjs" - } + "types": "./src/protocol.ts", + "import": "./src/protocol.ts" }, "./terminal": { - "import": { - "types": "./src/terminal.d.mts", - "default": "./src/terminal.mts" - }, - "require": { - "types": "./src/terminal.d.cts", - "default": "./src/terminal.cjs" - } + "types": "./src/terminal.ts", + "import": "./src/terminal.ts" }, "./child-process": { - "import": { - "types": "./src/child-process.d.mts", - "default": "./src/child-process.mts" - }, - "require": { - "types": "./src/child-process.d.cts", - "default": "./src/child-process.cjs" - } + "types": "./src/child-process.ts", + "import": "./src/child-process.ts" + }, + "./errors": { + "types": "./src/errors.ts", + "import": "./src/errors.ts" } }, "scripts": { diff --git a/packages/effect-acp/src/_generated/schema.gen.ts b/packages/effect-acp/src/_generated/schema.gen.ts index 3a86373fff2..e77163a9295 100644 --- a/packages/effect-acp/src/_generated/schema.gen.ts +++ b/packages/effect-acp/src/_generated/schema.gen.ts @@ -481,6 +481,17 @@ export const SessionCloseCapabilities = Schema.Struct({ "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/close` method.\n\nBy supplying `{}` it means that the agent supports closing of sessions.", }); +export type SessionConfigOptionCategory = "mode" | "model" | "thought_level" | string; +export const SessionConfigOptionCategory = Schema.Union([ + Schema.Literal("mode").annotate({ description: "Session mode selector." }), + Schema.Literal("model").annotate({ description: "Model selector." }), + Schema.Literal("thought_level").annotate({ description: "Thought/reasoning level selector." }), + Schema.String.annotate({ title: "other", description: "Unknown / uncategorized selector." }), +]).annotate({ + description: + "Semantic category for a session configuration option.\n\nThis is intended to help Clients distinguish broadly common selectors (e.g. model selector vs\nsession mode selector vs thought/reasoning level) for UX purposes (keyboard shortcuts, icons,\nplacement). It MUST NOT be required for correctness. Clients MUST handle missing or unknown\ncategories gracefully.\n\nCategory names beginning with `_` are free for custom use, like other ACP extension methods.\nCategory names that do not begin with `_` are reserved for the ACP spec.", +}); + export type SessionConfigSelectOption = { readonly _meta?: { readonly [x: string]: unknown } | null; readonly description?: string | null; @@ -1800,40 +1811,97 @@ export type SessionConfigOption = readonly options: | ReadonlyArray | ReadonlyArray; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly category?: SessionConfigOptionCategory | null; + readonly description?: string | null; + readonly id: string; + readonly name: string; } - | { readonly type: "boolean"; readonly currentValue: boolean }; -export const SessionConfigOption = Schema.Union( - [ - Schema.Struct({ - type: Schema.Literal("select"), - currentValue: Schema.String.annotate({ - description: "Unique identifier for a session configuration option value.", - }), - options: Schema.Union([ - Schema.Array(SessionConfigSelectOption).annotate({ - title: "Ungrouped", - description: "A flat list of options with no grouping.", + | { + readonly type: "boolean"; + readonly currentValue: boolean; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly category?: SessionConfigOptionCategory | null; + readonly description?: string | null; + readonly id: string; + readonly name: string; + }; +export const SessionConfigOption = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("select"), + currentValue: Schema.String.annotate({ + description: "Unique identifier for a session configuration option value.", + }), + options: Schema.Union([ + Schema.Array(SessionConfigSelectOption).annotate({ + title: "Ungrouped", + description: "A flat list of options with no grouping.", + }), + Schema.Array(SessionConfigSelectGroup).annotate({ + title: "Grouped", + description: "A list of options grouped under headers.", + }), + ]).annotate({ description: "Possible values for a session configuration option." }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", }), - Schema.Array(SessionConfigSelectGroup).annotate({ - title: "Grouped", - description: "A list of options grouped under headers.", + Schema.Null, + ]), + ), + category: Schema.optionalKey( + Schema.Union([SessionConfigOptionCategory, Schema.Null]).annotate({ + description: "Optional semantic category for this option (UX only).", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional description for the Client to display to the user.", }), - ]).annotate({ description: "Possible values for a session configuration option." }), - }).annotate({ - description: "A single-value selector (dropdown) session configuration option payload.", + Schema.Null, + ]), + ), + id: Schema.String.annotate({ + description: "Unique identifier for a session configuration option.", }), - Schema.Struct({ - type: Schema.Literal("boolean"), - currentValue: Schema.Boolean.annotate({ - description: "The current value of the boolean option.", + name: Schema.String.annotate({ description: "Human-readable label for the option." }), + }).annotate({ description: "A session configuration option selector and its current state." }), + Schema.Struct({ + type: Schema.Literal("boolean"), + currentValue: Schema.Boolean.annotate({ + description: "The current value of the boolean option.", + }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + category: Schema.optionalKey( + Schema.Union([SessionConfigOptionCategory, Schema.Null]).annotate({ + description: "Optional semantic category for this option (UX only).", }), - }).annotate({ - description: - "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA boolean on/off toggle session configuration option payload.", + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional description for the Client to display to the user.", + }), + Schema.Null, + ]), + ), + id: Schema.String.annotate({ + description: "Unique identifier for a session configuration option.", }), - ], - { mode: "oneOf" }, -).annotate({ description: "A session configuration option selector and its current state." }); + name: Schema.String.annotate({ description: "Human-readable label for the option." }), + }).annotate({ description: "A session configuration option selector and its current state." }), +]); export type SessionModeState = { readonly _meta?: { readonly [x: string]: unknown } | null; @@ -3005,8 +3073,18 @@ export type AgentRequest = { readonly title?: string | null; readonly type?: "object"; }; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly message: string; + readonly sessionId: string; + } + | { + readonly mode: "url"; + readonly elicitationId: string; + readonly url: string; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly message: string; + readonly sessionId: string; } - | { readonly mode: "url"; readonly elicitationId: string; readonly url: string } | unknown | null; }; @@ -3285,73 +3363,102 @@ export const AgentRequest = Schema.Struct({ title: "KillTerminalRequest", description: "Request to kill a terminal without releasing it.", }), - Schema.Union( - [ - Schema.Struct({ - mode: Schema.Literal("form"), - requestedSchema: Schema.Struct({ - description: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: "Optional description of what this schema represents.", - }), - Schema.Null, - ]), - ), - properties: Schema.optionalKey( - Schema.Record(Schema.String, ElicitationPropertySchema).annotate({ - description: "Property definitions (must be primitive types).", - default: {}, + Schema.Union([ + Schema.Struct({ + mode: Schema.Literal("form"), + requestedSchema: Schema.Struct({ + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional description of what this schema represents.", }), - ), - required: Schema.optionalKey( - Schema.Union([ - Schema.Array(Schema.String).annotate({ - description: "List of required property names.", - }), - Schema.Null, - ]), - ), - title: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ description: "Optional title for the schema." }), - Schema.Null, - ]), - ), - type: Schema.optionalKey( - Schema.Literal("object").annotate({ - description: "Type discriminator for elicitation schemas.", - default: "object", + Schema.Null, + ]), + ), + properties: Schema.optionalKey( + Schema.Record(Schema.String, ElicitationPropertySchema).annotate({ + description: "Property definitions (must be primitive types).", + default: {}, + }), + ), + required: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.String).annotate({ + description: "List of required property names.", }), - ), - }).annotate({ - description: - "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", - }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the schema." }), + Schema.Null, + ]), + ), + type: Schema.optionalKey( + Schema.Literal("object").annotate({ + description: "Type discriminator for elicitation schemas.", + default: "object", + }), + ), }).annotate({ description: - "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation mode where the client renders a form from the provided schema.", + "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", }), - Schema.Struct({ - mode: Schema.Literal("url"), - elicitationId: Schema.String.annotate({ - description: - "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.", - }), - url: Schema.String.annotate({ - description: "The URL to direct the user to.", - format: "uri", - }), - }).annotate({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + message: Schema.String.annotate({ + description: "A human-readable message describing what input is needed.", + }), + sessionId: Schema.String.annotate({ description: - "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation mode where the client directs the user to a URL.", + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", }), - ], - { mode: "oneOf" }, - ).annotate({ + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.", + }), + Schema.Struct({ + mode: Schema.Literal("url"), + elicitationId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.", + }), + url: Schema.String.annotate({ + description: "The URL to direct the user to.", + format: "uri", + }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + message: Schema.String.annotate({ + description: "A human-readable message describing what input is needed.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.", + }), + ]).annotate({ title: "ElicitationRequest", description: - "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.", + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequests structured user input via a form or URL.", }), Schema.Unknown.annotate({ title: "ExtMethodRequest", @@ -4490,8 +4597,19 @@ export type ClientRequest = { readonly modeId: string; readonly sessionId: string; } - | { readonly type: "boolean"; readonly value: boolean } - | { readonly value: string } + | { + readonly type: "boolean"; + readonly value: boolean; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configId: string; + readonly sessionId: string; + } + | { + readonly value: string; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configId: string; + readonly sessionId: string; + } | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly messageId?: string | null; @@ -4830,19 +4948,52 @@ export const ClientRequest = Schema.Struct({ Schema.Struct({ type: Schema.Literal("boolean"), value: Schema.Boolean.annotate({ description: "The boolean value." }), - }).annotate({ description: 'A boolean value (`type: "boolean"`).' }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configId: Schema.String.annotate({ + description: "Unique identifier for a session configuration option.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + description: "Request parameters for setting a session configuration option.", + }), Schema.Struct({ value: Schema.String.annotate({ description: "Unique identifier for a session configuration option value.", }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configId: Schema.String.annotate({ + description: "Unique identifier for a session configuration option.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), }).annotate({ title: "value_id", - description: - "A [`SessionConfigValueId`] string value.\n\nThis is the default when `type` is absent on the wire. Unknown `type`\nvalues with string payloads also gracefully deserialize into this\nvariant.", + description: "Request parameters for setting a session configuration option.", }), ]).annotate({ title: "SetSessionConfigOptionRequest", - description: "Request parameters for setting a session configuration option.", + description: "Sets the current value for a session configuration option.", }), Schema.Struct({ _meta: Schema.optionalKey( @@ -5818,72 +5969,106 @@ export type ElicitationRequest = readonly title?: string | null; readonly type?: "object"; }; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly message: string; + readonly sessionId: string; } - | { readonly mode: "url"; readonly elicitationId: string; readonly url: string }; -export const ElicitationRequest = Schema.Union( - [ - Schema.Struct({ - mode: Schema.Literal("form"), - requestedSchema: Schema.Struct({ - description: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: "Optional description of what this schema represents.", - }), - Schema.Null, - ]), - ), - properties: Schema.optionalKey( - Schema.Record(Schema.String, ElicitationPropertySchema).annotate({ - description: "Property definitions (must be primitive types).", - default: {}, - }), - ), - required: Schema.optionalKey( - Schema.Union([ - Schema.Array(Schema.String).annotate({ - description: "List of required property names.", - }), - Schema.Null, - ]), - ), - title: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ description: "Optional title for the schema." }), - Schema.Null, - ]), - ), - type: Schema.optionalKey( - Schema.Literal("object").annotate({ - description: "Type discriminator for elicitation schemas.", - default: "object", + | { + readonly mode: "url"; + readonly elicitationId: string; + readonly url: string; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly message: string; + readonly sessionId: string; + }; +export const ElicitationRequest = Schema.Union([ + Schema.Struct({ + mode: Schema.Literal("form"), + requestedSchema: Schema.Struct({ + description: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Optional description of what this schema represents.", }), - ), - }).annotate({ - description: - "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", - }), + Schema.Null, + ]), + ), + properties: Schema.optionalKey( + Schema.Record(Schema.String, ElicitationPropertySchema).annotate({ + description: "Property definitions (must be primitive types).", + default: {}, + }), + ), + required: Schema.optionalKey( + Schema.Union([ + Schema.Array(Schema.String).annotate({ description: "List of required property names." }), + Schema.Null, + ]), + ), + title: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "Optional title for the schema." }), + Schema.Null, + ]), + ), + type: Schema.optionalKey( + Schema.Literal("object").annotate({ + description: "Type discriminator for elicitation schemas.", + default: "object", + }), + ), }).annotate({ description: - "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation mode where the client renders a form from the provided schema.", + "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", }), - Schema.Struct({ - mode: Schema.Literal("url"), - elicitationId: Schema.String.annotate({ - description: - "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.", - }), - url: Schema.String.annotate({ description: "The URL to direct the user to.", format: "uri" }), - }).annotate({ + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + message: Schema.String.annotate({ + description: "A human-readable message describing what input is needed.", + }), + sessionId: Schema.String.annotate({ description: - "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation mode where the client directs the user to a URL.", + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", }), - ], - { mode: "oneOf" }, -).annotate({ - description: - "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.", -}); + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.", + }), + Schema.Struct({ + mode: Schema.Literal("url"), + elicitationId: Schema.String.annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.", + }), + url: Schema.String.annotate({ description: "The URL to direct the user to.", format: "uri" }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + message: Schema.String.annotate({ + description: "A human-readable message describing what input is needed.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ + description: + "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.", + }), +]); export type ElicitationResponse = { readonly _meta?: { readonly [x: string]: unknown } | null; @@ -7801,17 +7986,6 @@ export const SessionConfigId = Schema.String.annotate({ description: "Unique identifier for a session configuration option.", }); -export type SessionConfigOptionCategory = "mode" | "model" | "thought_level" | string; -export const SessionConfigOptionCategory = Schema.Union([ - Schema.Literal("mode").annotate({ description: "Session mode selector." }), - Schema.Literal("model").annotate({ description: "Model selector." }), - Schema.Literal("thought_level").annotate({ description: "Thought/reasoning level selector." }), - Schema.String.annotate({ title: "other", description: "Unknown / uncategorized selector." }), -]).annotate({ - description: - "Semantic category for a session configuration option.\n\nThis is intended to help Clients distinguish broadly common selectors (e.g. model selector vs\nsession mode selector vs thought/reasoning level) for UX purposes (keyboard shortcuts, icons,\nplacement). It MUST NOT be required for correctness. Clients MUST handle missing or unknown\ncategories gracefully.\n\nCategory names beginning with `_` are free for custom use, like other ACP extension methods.\nCategory names that do not begin with `_` are reserved for the ACP spec.", -}); - export type SessionConfigSelect = { readonly currentValue: string; readonly options: @@ -9533,23 +9707,65 @@ export const SessionUpdate = Schema.Union( }); export type SetSessionConfigOptionRequest = - | { readonly type: "boolean"; readonly value: boolean } - | { readonly value: string }; + | { + readonly type: "boolean"; + readonly value: boolean; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configId: string; + readonly sessionId: string; + } + | { + readonly value: string; + readonly _meta?: { readonly [x: string]: unknown } | null; + readonly configId: string; + readonly sessionId: string; + }; export const SetSessionConfigOptionRequest = Schema.Union([ Schema.Struct({ type: Schema.Literal("boolean"), value: Schema.Boolean.annotate({ description: "The boolean value." }), - }).annotate({ description: 'A boolean value (`type: "boolean"`).' }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configId: Schema.String.annotate({ + description: "Unique identifier for a session configuration option.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), + }).annotate({ description: "Request parameters for setting a session configuration option." }), Schema.Struct({ value: Schema.String.annotate({ description: "Unique identifier for a session configuration option value.", }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + configId: Schema.String.annotate({ + description: "Unique identifier for a session configuration option.", + }), + sessionId: Schema.String.annotate({ + description: + "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + }), }).annotate({ title: "value_id", - description: - "A [`SessionConfigValueId`] string value.\n\nThis is the default when `type` is absent on the wire. Unknown `type`\nvalues with string payloads also gracefully deserialize into this\nvariant.", + description: "Request parameters for setting a session configuration option.", }), -]).annotate({ description: "Request parameters for setting a session configuration option." }); +]); export type SetSessionConfigOptionResponse = { readonly _meta?: { readonly [x: string]: unknown } | null; diff --git a/packages/effect-acp/src/child-process.ts b/packages/effect-acp/src/child-process.ts index f9aaf971bb0..abfd8468c65 100644 --- a/packages/effect-acp/src/child-process.ts +++ b/packages/effect-acp/src/child-process.ts @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stdio from "effect/Stdio"; @@ -9,11 +10,13 @@ export function makeStdioFromChildProcess( handle: ChildProcessSpawner.ChildProcessHandle, ): Stdio.Stdio { return Stdio.make({ + args: Effect.succeed([]), stdin: handle.stdout, - stdout: Sink.mapInput(handle.stdin, (chunk) => - typeof chunk === "string" ? textEncoder.encode(chunk) : chunk, - ), - stderr: Sink.drain, + stdout: () => + Sink.mapInput(handle.stdin, (chunk: string | Uint8Array) => + typeof chunk === "string" ? textEncoder.encode(chunk) : chunk, + ), + stderr: () => Sink.drain, }); } diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index d9208503105..5c1fb9ca43b 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -1,6 +1,7 @@ import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -18,6 +19,8 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { Effect.gen(function* () { const updates = yield* Ref.make>([]); const elicitationCompletions = yield* Ref.make>([]); + const typedRequests = yield* Ref.make>([]); + const typedNotifications = yield* Ref.make>([]); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; @@ -49,6 +52,24 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { Ref.update(updates, (current) => [...current, notification]), elicitationComplete: (notification) => Ref.update(elicitationCompletions, (current) => [...current, notification]), + extRequests: { + "x/typed_request": AcpClient.defineExtRequest( + Schema.Struct({ message: Schema.String }), + (payload) => + Ref.update(typedRequests, (current) => [...current, payload]).pipe( + Effect.as({ + ok: true, + echoedMessage: payload.message, + }), + ), + ), + }, + extNotifications: { + "x/typed_notification": AcpClient.defineExtNotification( + Schema.Struct({ count: Schema.Number }), + (payload) => Ref.update(typedNotifications, (current) => [...current, payload]), + ), + }, }, }); @@ -85,6 +106,8 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { assert.equal(streamed[1]?._tag, "ElicitationComplete"); assert.equal((yield* Ref.get(updates)).length, 1); assert.equal((yield* Ref.get(elicitationCompletions)).length, 1); + assert.deepEqual(yield* Ref.get(typedRequests), [{ message: "hello from typed request" }]); + assert.deepEqual(yield* Ref.get(typedNotifications), [{ count: 2 }]); const ext = yield* client.extRequest("x/echo", { hello: "world", diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 53c2ca0fd6d..082b1b9dea7 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -14,6 +14,26 @@ import * as AcpSchema from "./_generated/schema.gen"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +export interface AcpExtensionRequestRegistration { + readonly payload: Schema.Schema; + readonly handler: (payload: A) => Effect.Effect; +} + +export interface AcpExtensionNotificationRegistration { + readonly payload: Schema.Schema; + readonly handler: (payload: A) => Effect.Effect; +} + +export const defineExtRequest = ( + payload: Schema.Schema, + handler: (payload: A) => Effect.Effect, +): AcpExtensionRequestRegistration => ({ payload, handler }); + +export const defineExtNotification = ( + payload: Schema.Schema, + handler: (payload: A) => Effect.Effect, +): AcpExtensionNotificationRegistration => ({ payload, handler }); + export interface AcpClientHandlers { /** * Handles `session/request_permission`. @@ -100,6 +120,10 @@ export interface AcpClientHandlers { method: string, params: unknown, ) => Effect.Effect; + /** + * Handles extension requests outside the core ACP method set using typed payload decoders. + */ + readonly extRequests?: Readonly>>; /** * Handles extension notifications outside the core ACP method set. * @see https://agentclientprotocol.com/protocol/extensibility @@ -108,6 +132,10 @@ export interface AcpClientHandlers { method: string, params: unknown, ) => Effect.Effect; + /** + * Handles extension notifications outside the core ACP method set using typed payload decoders. + */ + readonly extNotifications?: Readonly>>; } export interface AcpClientConnectOptions { @@ -257,16 +285,29 @@ export const fromChildProcess = Effect.fnUntraced(function* ( ? handlers.elicitationComplete(notification.params) : Effect.void; case "ExtNotification": - return handlers.extNotification - ? handlers.extNotification(notification.method, notification.params) - : Effect.void; + return runExtNotificationHandler( + handlers.extNotifications?.[notification.method], + handlers.extNotification, + notification.method, + notification.params, + ); case "SessionCancel": return handlers.extNotification ? handlers.extNotification(notification.method, notification.params) : Effect.void; } }, - ...(handlers.extRequest ? { onExtRequest: handlers.extRequest } : {}), + ...(handlers.extRequest || handlers.extRequests + ? { + onExtRequest: (method: string, params: unknown) => + runExtRequestHandler( + handlers.extRequests?.[method], + handlers.extRequest, + method, + params, + ), + } + : {}), }); const clientHandlerLayer = AcpRpcs.ClientRpcs.toLayer( @@ -354,3 +395,52 @@ const runHandler = Effect.fnUntraced(function* ( }), ); }); + +const decodeUnknownWith = ( + schema: Schema.Schema, + payload: unknown, +): Effect.Effect => + Effect.try({ + try: () => Schema.decodeUnknownSync(schema as never)(payload) as A, + catch: (cause) => + new AcpError.AcpProtocolParseError({ + detail: "Failed to decode typed ACP extension payload", + cause, + }), + }); + +const runExtRequestHandler = ( + registration: AcpExtensionRequestRegistration | undefined, + fallback: + | ((method: string, params: unknown) => Effect.Effect) + | undefined, + method: string, + params: unknown, +): Effect.Effect => { + if (registration) { + return decodeUnknownWith(registration.payload, params).pipe( + Effect.mapError(() => AcpError.AcpRequestError.invalidParams(`Invalid ${method} payload`)), + Effect.flatMap((payload) => registration.handler(payload)), + ); + } + if (fallback) { + return fallback(method, params); + } + return Effect.fail(AcpError.AcpRequestError.methodNotFound(method)); +}; + +const runExtNotificationHandler = ( + registration: AcpExtensionNotificationRegistration | undefined, + fallback: + | ((method: string, params: unknown) => Effect.Effect) + | undefined, + method: string, + params: unknown, +): Effect.Effect => { + if (registration) { + return decodeUnknownWith(registration.payload, params).pipe( + Effect.flatMap((payload) => registration.handler(payload)), + ); + } + return fallback ? fallback(method, params) : Effect.void; +}; diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 029ef4e67b0..390364c8592 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -21,11 +21,13 @@ function makeInMemoryStdio() { return { stdio: Stdio.make({ + args: Effect.succeed([]), stdin: Stream.fromQueue(input), - stdout: Sink.forEach((chunk: string | Uint8Array) => - Queue.offer(output, typeof chunk === "string" ? chunk : decoder.decode(chunk)), - ), - stderr: Sink.drain, + stdout: () => + Sink.forEach((chunk: string | Uint8Array) => + Queue.offer(output, typeof chunk === "string" ? chunk : decoder.decode(chunk)), + ), + stderr: () => Sink.drain, }), input, output, diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index d103babb1e2..553ff3cf392 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -44,12 +44,14 @@ export interface AcpPatchedProtocolOptions { readonly serverRequestMethods: ReadonlySet; readonly onNotification?: ( notification: AcpIncomingNotification, - ) => Effect.Effect; + ) => Effect.Effect; readonly onExtRequest?: ( method: string, params: unknown, - ) => Effect.Effect; - readonly onProcessExit?: (error: AcpError.AcpProcessExitedError) => Effect.Effect; + ) => Effect.Effect; + readonly onProcessExit?: ( + error: AcpError.AcpProcessExitedError, + ) => Effect.Effect; } export interface AcpPatchedProtocol { @@ -376,7 +378,7 @@ export const makeAcpPatchedProtocol = ( Effect.forkScoped, ); - yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout), Effect.forkScoped); + yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped); const clientProtocol = RpcClient.Protocol.of({ run: (f) => diff --git a/packages/effect-acp/src/schema.ts b/packages/effect-acp/src/schema.ts new file mode 100644 index 00000000000..5f40fa75396 --- /dev/null +++ b/packages/effect-acp/src/schema.ts @@ -0,0 +1,2 @@ +export * from "./_generated/schema.gen"; +export * from "./_generated/meta.gen"; diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index 0fff9c1f6f4..1a3f2269527 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -47,7 +47,12 @@ Effect.gen(function* () { const result = yield* client.prompt({ sessionId: session.sessionId, - prompt: [{ type: "text", text: "Summarize this repository." }], + prompt: [ + { + type: "text", + text: "Illustrate your ability to create todo lists and then execute all of them. Do not write the list to disk, illustrate your built in ability!", + }, + ], }); yield* Effect.logInfo("prompt result", result); diff --git a/packages/effect-acp/test/fixtures/acp-mock-peer.ts b/packages/effect-acp/test/fixtures/acp-mock-peer.ts index 593ba1820d3..ff8b61ea132 100644 --- a/packages/effect-acp/test/fixtures/acp-mock-peer.ts +++ b/packages/effect-acp/test/fixtures/acp-mock-peer.ts @@ -122,6 +122,8 @@ async function handleRequest(message: { }); await requestClient("session/elicitation", { + sessionId: "mock-session-1", + message: "Need confirmation before continuing.", mode: "form", requestedSchema: { type: "object", @@ -154,6 +156,14 @@ async function handleRequest(message: { elicitationId: "elicitation-1", }); + await requestClient("x/typed_request", { + message: "hello from typed request", + }); + + notify("x/typed_notification", { + count: 2, + }); + respond(message.id, { stopReason: "end_turn", }); diff --git a/patches/effect@4.0.0-beta.41.patch b/patches/effect@4.0.0-beta.41.patch new file mode 100644 index 00000000000..f7296bc10cb --- /dev/null +++ b/patches/effect@4.0.0-beta.41.patch @@ -0,0 +1,108 @@ +diff --git a/src/SchemaRepresentation.ts b/src/SchemaRepresentation.ts +index e1a87e2b..a7797678 100644 +--- a/src/SchemaRepresentation.ts ++++ b/src/SchemaRepresentation.ts +@@ -3007,7 +3007,14 @@ export function fromJsonSchemaMultiDocument(document: JsonSchema.MultiDocument<" + } + } + +- let out = on(js) ++ const hasAnyOf = Array.isArray(js.anyOf) ++ const hasOneOf = Array.isArray(js.oneOf) ++ const base = ++ hasAnyOf || hasOneOf ++ ? ({ ...js, anyOf: undefined, oneOf: undefined } as JsonSchema.JsonSchema) ++ : js ++ ++ let out = on(base) + + const annotations = collectAnnotations(js) + if (annotations !== undefined) { +@@ -3018,6 +3025,14 @@ export function fromJsonSchemaMultiDocument(document: JsonSchema.MultiDocument<" + return js.allOf.reduce((acc, curr) => combine(acc, recur(curr)), out) + } + ++ if (hasAnyOf) { ++ out = combine({ _tag: "Union", types: js.anyOf.map((type) => recur(type)), mode: "anyOf" }, out) ++ } ++ ++ if (hasOneOf) { ++ out = combine({ _tag: "Union", types: js.oneOf.map((type) => recur(type)), mode: "oneOf" }, out) ++ } ++ + return out + } + +@@ -3054,12 +3069,7 @@ export function fromJsonSchemaMultiDocument(document: JsonSchema.MultiDocument<" + } else { + return { _tag: "Union", types, mode: "anyOf" } + } +- } else if (Array.isArray(js.anyOf)) { +- return { _tag: "Union", types: js.anyOf.map((type) => recur(type)), mode: "anyOf" } +- } else if (Array.isArray(js.oneOf)) { +- return { _tag: "Union", types: js.oneOf.map((type) => recur(type)), mode: "oneOf" } + } +- + const type = isType(js.type) ? js.type : getType(js) + if (type !== undefined) { + switch (type) { +diff --git a/dist/SchemaRepresentation.js b/dist/SchemaRepresentation.js +index 0d3f01d7..c3672558 100644 +--- a/dist/SchemaRepresentation.js ++++ b/dist/SchemaRepresentation.js +@@ -2042,7 +2042,14 @@ export function fromJsonSchemaMultiDocument(document, options) { + js = {}; + } + } +- let out = on(js); ++ const hasAnyOf = Array.isArray(js.anyOf); ++ const hasOneOf = Array.isArray(js.oneOf); ++ const base = hasAnyOf || hasOneOf ? { ++ ...js, ++ anyOf: undefined, ++ oneOf: undefined ++ } : js; ++ let out = on(base); + const annotations = collectAnnotations(js); + if (annotations !== undefined) { + out = combine(out, { +@@ -2053,6 +2060,20 @@ export function fromJsonSchemaMultiDocument(document, options) { + if (Array.isArray(js.allOf)) { + return js.allOf.reduce((acc, curr) => combine(acc, recur(curr)), out); + } ++ if (hasAnyOf) { ++ out = combine({ ++ _tag: "Union", ++ types: js.anyOf.map(type => recur(type)), ++ mode: "anyOf" ++ }, out); ++ } ++ if (hasOneOf) { ++ out = combine({ ++ _tag: "Union", ++ types: js.oneOf.map(type => recur(type)), ++ mode: "oneOf" ++ }, out); ++ } + return out; + } + function on(js) { +@@ -2105,18 +2126,6 @@ export function fromJsonSchemaMultiDocument(document, options) { + mode: "anyOf" + }; + } +- } else if (Array.isArray(js.anyOf)) { +- return { +- _tag: "Union", +- types: js.anyOf.map(type => recur(type)), +- mode: "anyOf" +- }; +- } else if (Array.isArray(js.oneOf)) { +- return { +- _tag: "Union", +- types: js.oneOf.map(type => recur(type)), +- mode: "oneOf" +- }; + } + const type = isType(js.type) ? js.type : getType(js); + if (type !== undefined) { From 22594d6e22cf42984613bb2cd271da00e6502dc2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 12:56:21 -0700 Subject: [PATCH 23/82] nit --- apps/server/src/serverLayers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 59f5bca61fb..3852b43437a 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -35,6 +35,7 @@ import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; import { PtyAdapter } from "./terminal/Services/PTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { ChildProcessSpawner } from "effect/unstable/process"; type RuntimePtyAdapterLoader = { layer: Layer.Layer; @@ -61,6 +62,7 @@ export function makeServerProviderLayer(): Layer.Layer< | ServerSettingsService | FileSystem.FileSystem | AnalyticsService + | ChildProcessSpawner.ChildProcessSpawner > { return Effect.gen(function* () { const { providerEventLogPath } = yield* ServerConfig; @@ -81,7 +83,7 @@ export function makeServerProviderLayer(): Layer.Layer< ); const cursorAdapterLayer = makeCursorAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, - ).pipe(Layer.provideMerge(NodeServices.layer)); + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), From 0c218d218ebc858d20c952afcd4be3cc3fd4f23b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 13:07:28 -0700 Subject: [PATCH 24/82] noExternal --- apps/desktop/tsdown.config.ts | 2 +- apps/server/tsdown.config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index f3ebc973253..ffa599c0b28 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -12,7 +12,7 @@ export default defineConfig([ ...shared, entry: ["src/main.ts"], clean: true, - noExternal: (id) => id.startsWith("@t3tools/"), + noExternal: (id) => id.startsWith("@t3tools/") || id.startsWith("effect-acp"), }, { ...shared, diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts index f89bc7d3d76..43c68c5159a 100644 --- a/apps/server/tsdown.config.ts +++ b/apps/server/tsdown.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ outDir: "dist", sourcemap: true, clean: true, - noExternal: (id) => id.startsWith("@t3tools/"), + noExternal: (id) => id.startsWith("@t3tools/") || id.startsWith("effect-acp"), inlineOnly: false, banner: { js: "#!/usr/bin/env node\n", From f76321fc22ab53974315cbcf1fb38e476a761cb3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 15:01:47 -0700 Subject: [PATCH 25/82] Log ACP requests and preserve turn-start failures - add request and protocol logging for ACP sessions - validate session config values before sending them - keep provider turn-start failures on the thread session - add coverage for ACP runtime and orchestration errors Co-authored-by: codex --- apps/server/scripts/acp-mock-agent.ts | 20 ++ .../Layers/ProviderCommandReactor.test.ts | 161 +++++++++++ .../Layers/ProviderCommandReactor.ts | 70 ++++- .../src/provider/Layers/CursorAdapter.ts | 18 +- .../provider/acp/AcpJsonRpcConnection.test.ts | 141 +++++++++- .../src/provider/acp/AcpNativeLogging.ts | 76 +++++ .../src/provider/acp/AcpRuntimeModel.ts | 25 ++ .../src/provider/acp/AcpSessionRuntime.ts | 265 ++++++++++++++---- .../provider/acp/CursorAcpCliProbe.test.ts | 3 + .../provider/acp/CursorAcpExtension.test.ts | 45 ++- .../src/provider/acp/CursorAcpExtension.ts | 66 ++--- .../components/chat/MessagesTimeline.test.tsx | 47 ++++ .../src/components/chat/MessagesTimeline.tsx | 37 ++- packages/effect-acp/src/client.test.ts | 88 +++++- packages/effect-acp/src/client.ts | 93 +++--- packages/effect-acp/src/errors.ts | 75 ++--- packages/effect-acp/src/protocol.ts | 99 +++++-- .../examples/cursor-acp-client.example.ts | 10 +- .../effect-acp/test/fixtures/acp-mock-peer.ts | 19 +- 19 files changed, 1111 insertions(+), 247 deletions(-) create mode 100644 apps/server/src/provider/acp/AcpNativeLogging.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 9ab7c9783f3..64e502478dd 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -11,6 +11,8 @@ import { AGENT_METHODS, CLIENT_METHODS } from "effect-acp/schema"; const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; +const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const sessionId = "mock-session-1"; let currentModeId = "ask"; let currentModelId = "default"; @@ -181,6 +183,24 @@ rl.on("line", (line) => { } if (method === AGENT_METHODS.session_set_config_option && id !== undefined) { + if (exitOnSetConfigOption) { + process.exit(7); + } + if (failSetConfigOption) { + send({ + jsonrpc: "2.0", + id, + error: { + code: -32602, + message: "Mock invalid params for session/set_config_option", + data: { + method, + params: rpcMessage.params, + }, + }, + }); + return; + } const configId = rpcMessage.params?.configId; const value = rpcMessage.params?.value; if (configId === "model" && typeof value === "string") { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f74ce3a5a8d..570bdc8ab3b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -308,6 +308,61 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("records session lastError and clears active turn when provider turn start fails", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.sendTurn.mockImplementation( + () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "cursor", + method: "session/set_config_option", + detail: 'Invalid value for session config option "model"', + }), + ) as never, + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-session-error"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-session-error"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return ( + thread?.session?.lastError === + 'Provider adapter request failed (cursor) for session/set_config_option: Invalid value for session config option "model"' && + thread.session.status === "ready" && + thread.session.activeTurnId === null + ); + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session).toMatchObject({ + status: "ready", + activeTurnId: null, + lastError: + 'Provider adapter request failed (cursor) for session/set_config_option: Invalid value for session config option "model"', + }); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1096,6 +1151,112 @@ describe("ProviderCommandReactor", () => { }); }); + it("preserves provider method context when turn start fails", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.sendTurn.mockImplementation( + () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "cursor", + method: "session/set_config_option", + detail: "Invalid cursor/set_config_option payload: Expected string, got null", + }), + ) as never, + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-error"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-error"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining( + "Provider adapter request failed (cursor) for session/set_config_option", + ), + }, + }); + }); + + it("keeps the full rendered cause for non-adapter turn start failures", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.sendTurn.mockImplementation( + () => + Effect.fail( + new Error("Invalid params", { cause: new Error("session/prompt failed") }), + ) as never, + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-raw-error"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-raw-error"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("session/prompt failed"), + }, + }); + }); + it("reacts to thread.approval.respond by forwarding provider approval response", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 8cacbca933e..49efb5a9501 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -74,8 +74,15 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +function findProviderAdapterRequestError( + cause: Cause.Cause, +): ProviderAdapterRequestError | undefined { + const failReason = cause.reasons.find(Cause.isFailReason); + return Schema.is(ProviderAdapterRequestError)(failReason?.error) ? failReason.error : undefined; +} + function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { - const error = Cause.squash(cause); + const error = findProviderAdapterRequestError(cause); if (Schema.is(ProviderAdapterRequestError)(error)) { const detail = error.detail.toLowerCase(); return ( @@ -91,7 +98,7 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { - const error = Cause.squash(cause); + const error = findProviderAdapterRequestError(cause); if (Schema.is(ProviderAdapterRequestError)(error)) { return error.detail.toLowerCase().includes("unknown pending user-input request"); } @@ -186,6 +193,17 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); + const formatFailureDetail = (cause: Cause.Cause): string => { + const failReason = cause.reasons.find(Cause.isFailReason); + const providerError = Schema.is(ProviderAdapterRequestError)(failReason?.error) + ? failReason.error + : undefined; + if (providerError) { + return providerError.message; + } + return Cause.pretty(cause); + }; + const setThreadSession = (input: { readonly threadId: ThreadId; readonly session: OrchestrationSession; @@ -199,6 +217,29 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); + const setThreadSessionErrorOnTurnStartFailure = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly detail: string; + readonly createdAt: string; + }) { + const thread = yield* resolveThread(input.threadId); + const session = thread?.session; + if (!session) { + return; + } + yield* setThreadSession({ + threadId: input.threadId, + session: { + ...session, + status: session.status === "stopped" ? "stopped" : "ready", + activeTurnId: null, + lastError: input.detail, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { const readModel = yield* orchestrationEngine.getReadModel(); return readModel.threads.find((entry) => entry.id === threadId); @@ -501,16 +542,25 @@ const make = Effect.gen(function* () { interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( - Effect.catchCause((cause) => - appendProviderFailureActivity({ + Effect.catchCause((cause) => { + const detail = formatFailureDetail(cause); + return setThreadSessionErrorOnTurnStartFailure({ threadId: event.payload.threadId, - kind: "provider.turn.start.failed", - summary: "Provider turn start failed", - detail: Cause.pretty(cause), - turnId: null, + detail, createdAt: event.payload.createdAt, - }), - ), + }).pipe( + Effect.flatMap(() => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + detail, + turnId: null, + createdAt: event.payload.createdAt, + }), + ), + ); + }), ); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 0fc47002ee0..fcc1da70882 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -27,12 +27,10 @@ import { Layer, Queue, Random, - Schema, Stream, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { defineExtRequest } from "effect-acp/client"; -import * as EffectAcpErrors from "effect-acp/errors"; +import { defineExtNotification, defineExtRequest } from "effect-acp/client"; import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -59,6 +57,7 @@ import { type AcpSessionModeState, parsePermissionRequest, } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; import { CursorAskQuestionRequest, CursorCreatePlanRequest, @@ -358,6 +357,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { let ctx!: CursorSessionContext; const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); const acp = yield* makeAcpSessionRuntime({ spawn: spawnOptions, @@ -365,6 +369,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, authMethodId: "cursor_login", + ...acpNativeLoggers, handlers: { extRequests: { "cursor/ask_question": defineExtRequest(CursorAskQuestionRequest, (params) => @@ -431,7 +436,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return { accepted: true } as const; }), ), - "cursor/update_todos": defineExtRequest(CursorUpdateTodosRequest, (params) => + }, + extNotifications: { + "cursor/update_todos": defineExtNotification(CursorUpdateTodosRequest, (params) => Effect.gen(function* () { yield* logNative( input.threadId, @@ -448,7 +455,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { "cursor/update_todos", ); } - return {}; }), ), }, @@ -502,7 +508,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { optionId: acpPermissionOutcome(resolved), }, }; - }).pipe(Effect.mapError(EffectAcpErrors.normalizeAcpError)), + }), }, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index cba58bb2eb8..50b799242e1 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -1,12 +1,15 @@ import * as path from "node:path"; +import * as os from "node:os"; import { fileURLToPath } from "node:url"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, Stream } from "effect"; import { describe, expect } from "vitest"; -import { makeAcpSessionRuntime } from "./AcpSessionRuntime.ts"; +import { makeAcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts"; +import type * as EffectAcpProtocol from "effect-acp/protocol"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); @@ -22,6 +25,7 @@ describe("AcpSessionRuntime", () => { }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", }); expect(runtime.initializeResult).toMatchObject({ protocolVersion: 1 }); @@ -44,4 +48,139 @@ describe("AcpSessionRuntime", () => { yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("logs ACP requests from the shared runtime", () => + Effect.gen(function* () { + const requestEvents: Array = []; + const runtime = yield* makeAcpSessionRuntime({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }); + + yield* runtime.setModel("composer-2"); + yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + + expect( + requestEvents.some( + (event) => event.method === "session/set_config_option" && event.status === "started", + ), + ).toBe(true); + expect( + requestEvents.some( + (event) => event.method === "session/set_config_option" && event.status === "succeeded", + ), + ).toBe(true); + expect( + requestEvents.some( + (event) => event.method === "session/prompt" && event.status === "started", + ), + ).toBe(true); + expect( + requestEvents.some( + (event) => event.method === "session/prompt" && event.status === "succeeded", + ), + ).toBe(true); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => + Effect.gen(function* () { + const protocolEvents: Array = []; + const runtime = yield* makeAcpSessionRuntime({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + protocolLogging: { + logIncoming: true, + logOutgoing: true, + logger: (event) => + Effect.sync(() => { + protocolEvents.push(event); + }), + }, + }); + + yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + + expect( + protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "raw"), + ).toBe(true); + expect( + protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "decoded"), + ).toBe(true); + expect( + protocolEvents.some((event) => event.direction === "incoming" && event.stage === "raw"), + ).toBe(true); + expect( + protocolEvents.some((event) => event.direction === "incoming" && event.stage === "decoded"), + ).toBe(true); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects invalid config option values before sending session/set_config_option", () => + Effect.gen(function* () { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-")); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const runtime = yield* makeAcpSessionRuntime({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], + env: { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + }); + + const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip); + expect(error._tag).toBe("AcpRequestError"); + if (error._tag === "AcpRequestError") { + expect(error.code).toBe(-32602); + expect(error.message).toContain( + 'Invalid value "composer-2[fast=false]" for session config option "model"', + ); + expect(error.message).toContain("composer-2[fast=true]"); + } + + yield* runtime.close; + + const recordedRequests = readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } }); + expect( + recordedRequests.some( + (message) => + message.method === "session/set_config_option" && + message.params?.value === "composer-2[fast=false]", + ), + ).toBe(false); + + rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts new file mode 100644 index 00000000000..2fb3f4e8335 --- /dev/null +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -0,0 +1,76 @@ +import type { ProviderKind, ThreadId } from "@t3tools/contracts"; +import { Cause, Effect } from "effect"; +import type * as EffectAcpProtocol from "effect-acp/protocol"; + +import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; +import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpSessionRuntime.ts"; + +function writeNativeAcpLog(input: { + readonly nativeEventLogger: EventNdjsonLogger | undefined; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly kind: "request" | "protocol"; + readonly payload: unknown; +}): Effect.Effect { + return Effect.gen(function* () { + if (!input.nativeEventLogger) return; + const observedAt = new Date().toISOString(); + yield* input.nativeEventLogger.write( + { + observedAt, + event: { + id: crypto.randomUUID(), + kind: input.kind, + provider: input.provider, + createdAt: observedAt, + threadId: input.threadId, + payload: input.payload, + }, + }, + input.threadId, + ); + }); +} + +function formatRequestLogPayload(event: AcpSessionRequestLogEvent) { + return { + method: event.method, + status: event.status, + request: event.payload, + ...(event.result !== undefined ? { result: event.result } : {}), + ...(event.cause !== undefined ? { cause: Cause.pretty(event.cause) } : {}), + }; +} + +export function makeAcpNativeLoggers(input: { + readonly nativeEventLogger: EventNdjsonLogger | undefined; + readonly provider: ProviderKind; + readonly threadId: ThreadId; +}): Pick { + return { + requestLogger: (event) => + writeNativeAcpLog({ + nativeEventLogger: input.nativeEventLogger, + provider: input.provider, + threadId: input.threadId, + kind: "request", + payload: formatRequestLogPayload(event), + }), + ...(input.nativeEventLogger + ? { + protocolLogging: { + logIncoming: true, + logOutgoing: true, + logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => + writeNativeAcpLog({ + nativeEventLogger: input.nativeEventLogger, + provider: input.provider, + threadId: input.threadId, + kind: "protocol", + payload: event, + }), + } satisfies NonNullable, + } + : {}), + }; +} diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 73720df7e73..7755986a48c 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -86,6 +86,31 @@ export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): return undefined; } +export function findSessionConfigOption( + configOptions: ReadonlyArray | null | undefined, + configId: string, +): EffectAcpSchema.SessionConfigOption | undefined { + if (!configOptions) { + return undefined; + } + const normalizedConfigId = configId.trim(); + if (!normalizedConfigId) { + return undefined; + } + return configOptions.find((option) => option.id.trim() === normalizedConfigId); +} + +export function collectSessionConfigOptionValues( + configOption: EffectAcpSchema.SessionConfigOption, +): ReadonlyArray { + if (configOption.type !== "select") { + return []; + } + return configOption.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((option) => option.value), + ); +} + export function parseSessionModeState( sessionResponse: AcpSessionSetupResponse, ): AcpSessionModeState | undefined { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index d0540093327..31bf47f9591 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,11 +1,14 @@ -import { Effect, Exit, Queue, Ref, Scope, Stream } from "effect"; +import { Cause, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; +import type * as EffectAcpProtocol from "effect-acp/protocol"; import { + collectSessionConfigOptionValues, extractModelConfigId, + findSessionConfigOption, mergeToolCallState, parseSessionModeState, parseSessionUpdateEvent, @@ -29,8 +32,22 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId?: string; + readonly authMethodId: string; readonly handlers?: Omit; + readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; + readonly protocolLogging?: { + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect; + }; +} + +export interface AcpSessionRequestLogEvent { + readonly method: string; + readonly payload: unknown; + readonly status: "started" | "succeeded" | "failed"; + readonly result?: unknown; + readonly cause?: Cause.Cause; } export interface AcpSessionRuntime { @@ -79,6 +96,38 @@ export const makeAcpSessionRuntime = ( const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); + + const logRequest = (event: AcpSessionRequestLogEvent) => + options.requestLogger ? options.requestLogger(event) : Effect.void; + + const runLoggedRequest = ( + method: string, + payload: unknown, + effect: Effect.Effect, + ): Effect.Effect => + logRequest({ method, payload, status: "started" }).pipe( + Effect.flatMap(() => + effect.pipe( + Effect.tap((result) => + logRequest({ + method, + payload, + status: "succeeded", + result, + }), + ), + Effect.onError((cause) => + logRequest({ + method, + payload, + status: "failed", + cause, + }), + ), + ), + ), + ); const child = yield* spawner .spawn( @@ -100,6 +149,13 @@ export const makeAcpSessionRuntime = ( ); const client = yield* EffectAcpClient.fromChildProcess(child, { + ...(options.protocolLogging?.logIncoming !== undefined + ? { logIncoming: options.protocolLogging.logIncoming } + : {}), + ...(options.protocolLogging?.logOutgoing !== undefined + ? { logOutgoing: options.protocolLogging.logOutgoing } + : {}), + ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), handlers: { ...options.handlers, sessionUpdate: (notification) => @@ -112,18 +168,30 @@ export const makeAcpSessionRuntime = ( }, }).pipe(Effect.provideService(Scope.Scope, runtimeScope)); - const initializeResult = yield* client.initialize({ + const initializePayload = { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, }, clientInfo: options.clientInfo, - }); + } satisfies EffectAcpSchema.InitializeRequest; + + const initializeResult = yield* runLoggedRequest( + "initialize", + initializePayload, + client.initialize(initializePayload), + ); + + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* client.authenticate({ - methodId: options.authMethodId ?? "cursor_login", - }); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + client.authenticate(authenticatePayload), + ); let sessionId: string; let sessionSetupResult: @@ -131,37 +199,135 @@ export const makeAcpSessionRuntime = ( | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse; if (options.resumeSessionId) { - const resumed = yield* client - .loadSession({ - sessionId: options.resumeSessionId, - cwd: options.cwd, - mcpServers: [], - }) - .pipe(Effect.exit); + const loadPayload = { + sessionId: options.resumeSessionId, + cwd: options.cwd, + mcpServers: [], + } satisfies EffectAcpSchema.LoadSessionRequest; + const resumed = yield* runLoggedRequest( + "session/load", + loadPayload, + client.loadSession(loadPayload), + ).pipe(Effect.exit); if (Exit.isSuccess(resumed)) { sessionId = options.resumeSessionId; sessionSetupResult = resumed.value; } else { - const created = yield* client.createSession({ + const createPayload = { cwd: options.cwd, mcpServers: [], - }); + } satisfies EffectAcpSchema.NewSessionRequest; + const created = yield* runLoggedRequest( + "session/new", + createPayload, + client.createSession(createPayload), + ); sessionId = created.sessionId; sessionSetupResult = created; } } else { - const created = yield* client.createSession({ + const createPayload = { cwd: options.cwd, mcpServers: [], - }); + } satisfies EffectAcpSchema.NewSessionRequest; + const created = yield* runLoggedRequest( + "session/new", + createPayload, + client.createSession(createPayload), + ); sessionId = created.sessionId; sessionSetupResult = created; } yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); const close = Scope.close(runtimeScope, Exit.void).pipe(Effect.asVoid); + const validateConfigOptionValue = ( + configId: string, + value: string | boolean, + ): Effect.Effect => + Effect.gen(function* () { + const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId); + if (!configOption) { + return; + } + if (configOption.type === "boolean") { + if (typeof value === "boolean") { + return; + } + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${JSON.stringify(value)} for session config option "${configOption.id}": expected boolean`, + data: { + configId: configOption.id, + expectedType: "boolean", + receivedValue: value, + }, + }); + } + if (typeof value !== "string") { + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${JSON.stringify(value)} for session config option "${configOption.id}": expected string`, + data: { + configId: configOption.id, + expectedType: "string", + receivedValue: value, + }, + }); + } + const allowedValues = collectSessionConfigOptionValues(configOption); + if (allowedValues.includes(value)) { + return; + } + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid value ${JSON.stringify(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`, + data: { + configId: configOption.id, + allowedValues, + receivedValue: value, + }, + }); + }); + + const updateConfigOptions = ( + response: + | EffectAcpSchema.SetSessionConfigOptionResponse + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, + ): Effect.Effect => Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(response)); + + const setConfigOption = ( + configId: string, + value: string | boolean, + ): Effect.Effect => + validateConfigOptionValue(configId, value).pipe( + Effect.flatMap(() => { + const requestPayload = + typeof value === "boolean" + ? ({ + sessionId, + configId, + type: "boolean", + value, + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) + : ({ + sessionId, + configId, + value: String(value), + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); + return runLoggedRequest( + "session/set_config_option", + requestPayload, + client.setSessionConfigOption(requestPayload), + ).pipe(Effect.tap((response) => updateConfigOptions(response))); + }), + ); + return { sessionId, initializeResult, @@ -169,46 +335,47 @@ export const makeAcpSessionRuntime = ( modelConfigId: extractModelConfigId(sessionSetupResult), events: Stream.fromQueue(eventQueue), getModeState: Ref.get(modeStateRef), - prompt: (payload) => - client.prompt({ + prompt: (payload) => { + const requestPayload = { sessionId, ...payload, - }), + } satisfies EffectAcpSchema.PromptRequest; + return runLoggedRequest("session/prompt", requestPayload, client.prompt(requestPayload)); + }, cancel: client.cancel({ sessionId }), - setMode: (modeId) => - client.setSessionMode({ + setMode: (modeId) => { + const requestPayload = { sessionId, modeId, - }), - setConfigOption: (configId, value) => - client.setSessionConfigOption( - typeof value === "boolean" - ? ({ - sessionId, - configId, - type: "boolean", - value, - } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) - : ({ - sessionId, - configId, - value: String(value), - } satisfies EffectAcpSchema.SetSessionConfigOptionRequest), - ), + } satisfies EffectAcpSchema.SetSessionModeRequest; + return runLoggedRequest( + "session/set_mode", + requestPayload, + client.setSessionMode(requestPayload), + ); + }, + setConfigOption, setModel: (model) => - client - .setSessionConfigOption({ - sessionId, - configId: extractModelConfigId(sessionSetupResult) ?? "model", - value: model, - }) - .pipe(Effect.asVoid), - request: client.extRequest, + setConfigOption(extractModelConfigId(sessionSetupResult) ?? "model", model).pipe( + Effect.asVoid, + ), + request: (method, payload) => + runLoggedRequest(method, payload, client.extRequest(method, payload)), notify: client.extNotification, close, } satisfies AcpSessionRuntime; }); +function sessionConfigOptionsFromSetup( + response: + | { + readonly configOptions?: ReadonlyArray | null; + } + | undefined, +): ReadonlyArray { + return response?.configOptions ?? []; +} + const handleSessionUpdate = ({ queue, modeStateRef, @@ -219,7 +386,7 @@ const handleSessionUpdate = ({ readonly modeStateRef: Ref.Ref; readonly toolCallsRef: Ref.Ref>; readonly params: EffectAcpSchema.SessionNotification; -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { const parsed = parseSessionUpdateEvent(params); if (parsed.modeId) { @@ -249,7 +416,7 @@ const handleSessionUpdate = ({ } yield* Queue.offer(queue, event); } - }).pipe(Effect.mapError(EffectAcpErrors.normalizeAcpError)); + }); function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState { const normalized = nextModeId.trim(); diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index d577fc56cb6..d6b771823a1 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -21,6 +21,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", }, cwd: process.cwd(), clientInfo: { name: "t3-probe", version: "0.0.0" }, + authMethodId: "cursor_login", }); expect(runtime.initializeResult).toBeDefined(); yield* runtime.close; @@ -30,6 +31,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", it.effect("session/new returns configOptions with a model selector", () => Effect.gen(function* () { const runtime = yield* makeAcpSessionRuntime({ + authMethodId: "cursor_login", spawn: { command: "agent", args: ["acp"], @@ -59,6 +61,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", it.effect("session/set_config_option switches the model in-session", () => Effect.gen(function* () { const runtime = yield* makeAcpSessionRuntime({ + authMethodId: "cursor_login", spawn: { command: "agent", args: ["acp"], diff --git a/apps/server/src/provider/acp/CursorAcpExtension.test.ts b/apps/server/src/provider/acp/CursorAcpExtension.test.ts index 331c389a5c1..91d50c4a9b8 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.test.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.test.ts @@ -29,6 +29,7 @@ describe("CursorAcpExtension", () => { id: "language", header: "Question", question: "Which language should I use?", + multiSelect: false, options: [ { label: "TypeScript", description: "TypeScript" }, { label: "Rust", description: "Rust" }, @@ -37,6 +38,35 @@ describe("CursorAcpExtension", () => { ]); }); + it("defaults ask-question multi-select to false when Cursor omits allowMultiple", () => { + const questions = extractAskQuestions({ + toolCallId: "ask-2", + questions: [ + { + id: "mode", + prompt: "Which mode should I use?", + options: [ + { id: "agent", label: "Agent" }, + { id: "plan", label: "Plan" }, + ], + }, + ], + }); + + expect(questions).toEqual([ + { + id: "mode", + header: "Question", + question: "Which mode should I use?", + multiSelect: false, + options: [ + { label: "Agent", description: "Agent" }, + { label: "Plan", description: "Plan" }, + ], + }, + ]); + }); + it("extracts plan markdown from the real Cursor create-plan payload shape", () => { const planMarkdown = extractPlanMarkdown({ toolCallId: "plan-1", @@ -53,20 +83,25 @@ describe("CursorAcpExtension", () => { expect(planMarkdown).toBe("# Plan\n\n1. Add schemas\n2. Remove casts"); }); - it("projects todo updates into a plan shape", () => { + it("projects todo updates into a plan shape and drops invalid entries", () => { expect( extractTodosAsPlan({ + toolCallId: "todos-1", todos: [ - { content: "Inspect state", status: "completed" }, - { title: "Apply fix", status: "in_progress" }, - {}, + { id: "1", content: "Inspect state", status: "completed" }, + { id: "2", content: " Apply fix ", status: "in_progress" }, + { id: "3", title: "Fallback title", status: "pending" }, + { id: "4", content: "Unknown status", status: "weird_status" }, + { id: "5", content: " " }, ], + merge: true, }), ).toEqual({ plan: [ { step: "Inspect state", status: "completed" }, { step: "Apply fix", status: "inProgress" }, - { step: "Step 3", status: "pending" }, + { step: "Fallback title", status: "pending" }, + { step: "Unknown status", status: "pending" }, ], }); }); diff --git a/apps/server/src/provider/acp/CursorAcpExtension.ts b/apps/server/src/provider/acp/CursorAcpExtension.ts index abc78a1c8af..787261af979 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.ts @@ -10,35 +10,31 @@ const CursorAskQuestion = Schema.Struct({ id: Schema.String, prompt: Schema.String, options: Schema.Array(CursorAskQuestionOption), - allowMultiple: Schema.Boolean, + allowMultiple: Schema.optional(Schema.Boolean), }); export const CursorAskQuestionRequest = Schema.Struct({ - toolCallId: Schema.optional(Schema.String), + toolCallId: Schema.String, title: Schema.optional(Schema.String), questions: Schema.Array(CursorAskQuestion), }); -const CursorTodoStatus = Schema.Union([ - Schema.Literal("pending"), - Schema.Literal("in_progress"), - Schema.Literal("completed"), - Schema.Literal("cancelled"), -]); +const CursorTodoStatus = Schema.String; const CursorTodo = Schema.Struct({ id: Schema.optional(Schema.String), content: Schema.optional(Schema.String), - status: CursorTodoStatus, + title: Schema.optional(Schema.String), + status: Schema.optional(CursorTodoStatus), }); const CursorPlanPhase = Schema.Struct({ - name: Schema.optional(Schema.String), + name: Schema.String, todos: Schema.Array(CursorTodo), }); export const CursorCreatePlanRequest = Schema.Struct({ - toolCallId: Schema.optional(Schema.String), + toolCallId: Schema.String, name: Schema.optional(Schema.String), overview: Schema.optional(Schema.String), plan: Schema.String, @@ -47,7 +43,11 @@ export const CursorCreatePlanRequest = Schema.Struct({ phases: Schema.optional(Schema.Array(CursorPlanPhase)), }); -export const CursorUpdateTodosRequest = Schema.Unknown; +export const CursorUpdateTodosRequest = Schema.Struct({ + toolCallId: Schema.String, + todos: Schema.Array(CursorTodo), + merge: Schema.Boolean, +}); export function extractAskQuestions( params: typeof CursorAskQuestionRequest.Type, @@ -56,7 +56,7 @@ export function extractAskQuestions( id: question.id, header: "Question", question: question.prompt, - multiSelect: question.allowMultiple, + multiSelect: question.allowMultiple === true, options: question.options.length > 0 ? question.options.map((option) => ({ @@ -78,39 +78,23 @@ export function extractTodosAsPlan(params: typeof CursorUpdateTodosRequest.Type) readonly status: "pending" | "inProgress" | "completed"; }>; } { - if (typeof params !== "object" || params === null) { - return { plan: [] }; - } - const record = params as { - readonly todos?: ReadonlyArray<{ - readonly content?: string; - readonly title?: string; - readonly status?: string; - }>; - readonly items?: ReadonlyArray<{ - readonly content?: string; - readonly title?: string; - readonly status?: string; - }>; - }; - const todos = record.todos ?? record.items; - if (!todos) { - return { plan: [] }; - } - const plan = todos.map((t, i) => { + const plan = params.todos.flatMap((todo) => { const step = - typeof t?.content === "string" - ? t.content - : typeof t?.title === "string" - ? t.title - : `Step ${i + 1}`; + typeof todo.content === "string" + ? todo.content.trim() + : typeof todo.title === "string" + ? todo.title.trim() + : ""; + if (!step) { + return []; + } const status: "pending" | "inProgress" | "completed" = - t?.status === "completed" + todo.status === "completed" ? "completed" - : t?.status === "in_progress" || t?.status === "inProgress" + : todo.status === "in_progress" || todo.status === "inProgress" ? "inProgress" : "pending"; - return { step, status }; + return [{ step, status }]; }); return { plan }; } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74aa..32d608c909f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -140,4 +140,51 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("adds a hover title for full work log text", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain( + 'title="Provider turn start failed - Provider adapter request failed (cursor) for session/set_config_option: Invalid value for session config option "model""', + ); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index aac7603efe8..e0ccde846ec 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -43,6 +43,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, @@ -880,19 +881,29 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
-

- - {heading} - - {preview && - {preview}} -

+ + +

+ + {heading} + + {preview && - {preview}} +

+
+ +

{displayText}

+
+
{hasChangedFiles && !previewIsChangedFiles && ( diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 5c1fb9ca43b..91734127dbf 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -1,4 +1,5 @@ import * as Path from "effect/Path"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -15,20 +16,25 @@ const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => ); it.layer(NodeServices.layer)("effect-acp client", (it) => { - it.effect("initializes, prompts, receives updates, and handles permission requests", () => + const makeHandle = (env?: Record) => Effect.gen(function* () { - const updates = yield* Ref.make>([]); - const elicitationCompletions = yield* Ref.make>([]); - const typedRequests = yield* Ref.make>([]); - const typedNotifications = yield* Ref.make>([]); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; - const command = ChildProcess.make("bun", ["run", yield* mockPeerPath], { cwd: path.join(import.meta.dirname, ".."), shell: process.platform === "win32", + ...(env ? { env: { ...process.env, ...env } } : {}), }); - const handle = yield* spawner.spawn(command); + return yield* spawner.spawn(command); + }); + + it.effect("initializes, prompts, receives updates, and handles permission requests", () => + Effect.gen(function* () { + const updates = yield* Ref.make>([]); + const elicitationCompletions = yield* Ref.make>([]); + const typedRequests = yield* Ref.make>([]); + const typedNotifications = yield* Ref.make>([]); + const handle = yield* makeHandle(); const client = yield* AcpClient.fromChildProcess(handle, { handlers: { @@ -120,4 +126,72 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }); }), ); + + it.effect( + "returns formatted invalid params when a typed extension request payload is wrong", + () => + Effect.gen(function* () { + const handle = yield* makeHandle({ ACP_MOCK_BAD_TYPED_REQUEST: "1" }); + + const client = yield* AcpClient.fromChildProcess(handle, { + handlers: { + requestPermission: () => + Effect.succeed({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }), + elicitation: () => + Effect.succeed({ + action: { + action: "accept", + content: { + approved: true, + }, + }, + }), + extRequests: { + "x/typed_request": AcpClient.defineExtRequest( + Schema.Struct({ message: Schema.String }), + () => Effect.succeed({ ok: true }), + ), + }, + }, + }); + + yield* client.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: "effect-acp-test", + version: "0.0.0", + }, + }); + + yield* client.authenticate({ methodId: "cursor_login" }); + + const session = yield* client.createSession({ + cwd: process.cwd(), + mcpServers: [], + }); + + const result = yield* Effect.exit( + client.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "hello" }], + }), + ); + + if (result._tag !== "Failure") { + assert.fail("Expected prompt to fail for invalid typed extension payload"); + } + const rendered = Cause.pretty(result.cause); + assert.include(rendered, "Invalid x/typed_request payload:"); + assert.include(rendered, "Expected string, got 123"); + }), + ); }); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 082b1b9dea7..360927f4db5 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; @@ -13,26 +14,27 @@ import * as AcpServer from "./server"; import * as AcpSchema from "./_generated/schema.gen"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { RpcClientError } from "effect/unstable/rpc"; -export interface AcpExtensionRequestRegistration { - readonly payload: Schema.Schema; +export interface AcpExtensionRequestRegistration { + readonly payload: Schema.Codec; readonly handler: (payload: A) => Effect.Effect; } -export interface AcpExtensionNotificationRegistration { - readonly payload: Schema.Schema; +export interface AcpExtensionNotificationRegistration { + readonly payload: Schema.Codec; readonly handler: (payload: A) => Effect.Effect; } -export const defineExtRequest = ( - payload: Schema.Schema, +export const defineExtRequest = ( + payload: Schema.Codec, handler: (payload: A) => Effect.Effect, -): AcpExtensionRequestRegistration => ({ payload, handler }); +): AcpExtensionRequestRegistration => ({ payload, handler }); -export const defineExtNotification = ( - payload: Schema.Schema, +export const defineExtNotification = ( + payload: Schema.Codec, handler: (payload: A) => Effect.Effect, -): AcpExtensionNotificationRegistration => ({ payload, handler }); +): AcpExtensionNotificationRegistration => ({ payload, handler }); export interface AcpClientHandlers { /** @@ -141,6 +143,9 @@ export interface AcpClientHandlers { export interface AcpClientConnectOptions { readonly command: ChildProcess.Command; readonly handlers?: AcpClientHandlers; + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; } export interface AcpClientConnection { @@ -270,12 +275,18 @@ export const fromChildProcess = Effect.fnUntraced(function* ( handle: ChildProcessSpawner.ChildProcessHandle, options: { readonly handlers?: AcpClientHandlers; + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; } = {}, ): Effect.fn.Return { const handlers = options.handlers ?? {}; const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio: makeStdioFromChildProcess(handle), serverRequestMethods: new Set(AcpRpcs.ClientRpcs.requests.keys()), + ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}), + ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}), + ...(options.logger ? { logger: options.logger } : {}), onNotification: (notification) => { switch (notification._tag) { case "SessionUpdate": @@ -349,8 +360,20 @@ export const fromChildProcess = Effect.fnUntraced(function* ( Effect.provideService(RpcClient.Protocol, transport.clientProtocol), ); - const callRpc = (effect: Effect.Effect) => - effect.pipe(Effect.mapError(AcpError.normalizeAcpError)); + const callRpc = (effect: Effect.Effect) => + effect.pipe( + Effect.catchTag("RpcClientError", (error) => + Effect.fail( + new AcpError.AcpTransportError({ + detail: error.message, + cause: error, + }), + ), + ), + Effect.catchIf(Schema.is(AcpSchema.Error), (error) => + Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)), + ), + ); const server = AcpServer.makeAcpServerConnection(transport); @@ -388,29 +411,17 @@ const runHandler = Effect.fnUntraced(function* ( } return yield* handler(payload).pipe( Effect.mapError((error) => { - const normalized = AcpError.normalizeAcpError(error); - return Schema.is(AcpError.AcpRequestError)(normalized) - ? normalized.toProtocolError() - : AcpError.AcpRequestError.internalError(normalized.message).toProtocolError(); + return Schema.is(AcpError.AcpRequestError)(error) + ? error.toProtocolError() + : AcpError.AcpRequestError.internalError(error.message).toProtocolError(); }), ); }); -const decodeUnknownWith = ( - schema: Schema.Schema, - payload: unknown, -): Effect.Effect => - Effect.try({ - try: () => Schema.decodeUnknownSync(schema as never)(payload) as A, - catch: (cause) => - new AcpError.AcpProtocolParseError({ - detail: "Failed to decode typed ACP extension payload", - cause, - }), - }); +const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); -const runExtRequestHandler = ( - registration: AcpExtensionRequestRegistration | undefined, +const runExtRequestHandler = ( + registration: AcpExtensionRequestRegistration | undefined, fallback: | ((method: string, params: unknown) => Effect.Effect) | undefined, @@ -418,8 +429,13 @@ const runExtRequestHandler = ( params: unknown, ): Effect.Effect => { if (registration) { - return decodeUnknownWith(registration.payload, params).pipe( - Effect.mapError(() => AcpError.AcpRequestError.invalidParams(`Invalid ${method} payload`)), + return Schema.decodeUnknownEffect(registration.payload)(params).pipe( + Effect.mapError((error) => + AcpError.AcpRequestError.invalidParams( + `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, + { issue: error.issue }, + ), + ), Effect.flatMap((payload) => registration.handler(payload)), ); } @@ -429,8 +445,8 @@ const runExtRequestHandler = ( return Effect.fail(AcpError.AcpRequestError.methodNotFound(method)); }; -const runExtNotificationHandler = ( - registration: AcpExtensionNotificationRegistration | undefined, +const runExtNotificationHandler = ( + registration: AcpExtensionNotificationRegistration | undefined, fallback: | ((method: string, params: unknown) => Effect.Effect) | undefined, @@ -438,7 +454,14 @@ const runExtNotificationHandler = ( params: unknown, ): Effect.Effect => { if (registration) { - return decodeUnknownWith(registration.payload, params).pipe( + return Schema.decodeUnknownEffect(registration.payload)(params).pipe( + Effect.mapError( + (error) => + new AcpError.AcpProtocolParseError({ + detail: `Invalid ${method} notification payload: ${formatSchemaIssue(error.issue)}`, + cause: error, + }), + ), Effect.flatMap((payload) => registration.handler(payload)), ); } diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index 0c216f915de..496fd01b253 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -1,13 +1,10 @@ import * as Schema from "effect/Schema"; -import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; import * as AcpSchema from "./_generated/schema.gen"; -export type AcpProtocolError = AcpSchema.Error; - export class AcpSpawnError extends Schema.TaggedErrorClass()("AcpSpawnError", { command: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), + cause: Schema.Defect, }) { override get message() { return this.command @@ -46,16 +43,12 @@ export class AcpTransportError extends Schema.TaggedErrorClass()("AcpRequestError", { - code: Schema.Number, + code: AcpSchema.ErrorCode, errorMessage: Schema.String, data: Schema.optional(Schema.Unknown), }) { @@ -63,7 +56,7 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( return this.errorMessage; } - static fromProtocolError(error: AcpProtocolError) { + static fromProtocolError(error: AcpSchema.Error) { return new AcpRequestError({ code: error.code, errorMessage: error.message, @@ -126,57 +119,21 @@ export class AcpRequestError extends Schema.TaggedErrorClass()( }); } - toProtocolError(): AcpProtocolError { - return { + toProtocolError() { + return AcpSchema.Error.makeUnsafe({ code: this.code, message: this.errorMessage, ...(this.data !== undefined ? { data: this.data } : {}), - }; - } -} - -export type AcpError = - | AcpRequestError - | AcpSpawnError - | AcpProcessExitedError - | AcpProtocolParseError - | AcpTransportError; - -export function normalizeAcpError(error: unknown): AcpError { - if ( - Schema.is(AcpRequestError)(error) || - Schema.is(AcpSpawnError)(error) || - Schema.is(AcpProcessExitedError)(error) || - Schema.is(AcpProtocolParseError)(error) || - Schema.is(AcpTransportError)(error) - ) { - return error; - } - - if (Schema.is(RpcClientError.RpcClientError)(error)) { - return new AcpTransportError({ - detail: error.message, - cause: error, }); } - - if (isProtocolError(error)) { - return AcpRequestError.fromProtocolError(error); - } - - return new AcpTransportError({ - detail: error instanceof Error ? error.message : String(error), - ...(error !== undefined ? { cause: error } : {}), - }); } -function isProtocolError(value: unknown): value is AcpProtocolError { - return ( - typeof value === "object" && - value !== null && - "code" in value && - typeof value.code === "number" && - "message" in value && - typeof value.message === "string" - ); -} +export const AcpError = Schema.Union([ + AcpRequestError, + AcpSpawnError, + AcpProcessExitedError, + AcpProtocolParseError, + AcpTransportError, +]); + +export type AcpError = typeof AcpError.Type; diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 553ff3cf392..24c62863cc9 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -17,6 +17,12 @@ import * as AcpSchema from "./_generated/schema.gen"; import { CLIENT_METHODS } from "./_generated/meta.gen"; import * as AcpError from "./errors"; +export interface AcpProtocolLogEvent { + readonly direction: "incoming" | "outgoing"; + readonly stage: "raw" | "decoded" | "decode_failed"; + readonly payload: unknown; +} + export type AcpIncomingNotification = | { readonly _tag: "SessionUpdate"; @@ -42,6 +48,9 @@ export type AcpIncomingNotification = export interface AcpPatchedProtocolOptions { readonly stdio: Stdio.Stdio; readonly serverRequestMethods: ReadonlySet; + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect; readonly onNotification?: ( notification: AcpIncomingNotification, ) => Effect.Effect; @@ -95,6 +104,19 @@ export const makeAcpPatchedProtocol = ( new Map>(), ); + const logProtocol = (event: AcpProtocolLogEvent) => { + if (event.direction === "incoming" && !options.logIncoming) { + return Effect.void; + } + if (event.direction === "outgoing" && !options.logOutgoing) { + return Effect.void; + } + return ( + options.logger?.(event) ?? + Effect.logDebug("ACP protocol event").pipe(Effect.annotateLogs({ event })) + ); + }; + const offerOutgoing = (message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded) => Effect.try({ try: () => parser.encode(message), @@ -104,8 +126,21 @@ export const makeAcpPatchedProtocol = ( cause, }), }).pipe( + Effect.tap(() => + logProtocol({ + direction: "outgoing", + stage: "decoded", + payload: message, + }), + ), Effect.flatMap((encoded) => - encoded === undefined ? Effect.void : Queue.offer(outgoing, encoded).pipe(Effect.asVoid), + encoded === undefined + ? Effect.void + : logProtocol({ + direction: "outgoing", + stage: "raw", + payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded), + }).pipe(Effect.flatMap(() => Queue.offer(outgoing, encoded).pipe(Effect.asVoid))), ), ); @@ -325,17 +360,41 @@ export const makeAcpPatchedProtocol = ( yield* options.stdio.stdin.pipe( Stream.runForEach((data) => - Effect.try({ - try: () => - parser.decode(data) as ReadonlyArray< - RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded - >, - catch: (cause) => - new AcpError.AcpProtocolParseError({ - detail: "Failed to decode ACP wire message", - cause, - }), + logProtocol({ + direction: "incoming", + stage: "raw", + payload: typeof data === "string" ? data : new TextDecoder().decode(data), }).pipe( + Effect.flatMap(() => + Effect.try({ + try: () => + parser.decode(data) as ReadonlyArray< + RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded + >, + catch: (cause) => + new AcpError.AcpProtocolParseError({ + detail: "Failed to decode ACP wire message", + cause, + }), + }), + ), + Effect.tap((messages) => + logProtocol({ + direction: "incoming", + stage: "decoded", + payload: messages, + }), + ), + Effect.tapErrorTag("AcpProtocolParseError", (error) => + logProtocol({ + direction: "incoming", + stage: "decode_failed", + payload: { + detail: error.detail, + cause: error.cause, + }, + }), + ), Effect.flatMap((messages) => Effect.forEach(messages, routeDecodedMessage, { discard: true, @@ -344,7 +403,12 @@ export const makeAcpPatchedProtocol = ( ), ), Effect.catch((error) => { - const normalized = AcpError.normalizeAcpError(error); + const normalized: AcpError.AcpError = Schema.is(AcpError.AcpError)(error) + ? error + : new AcpError.AcpTransportError({ + detail: error instanceof Error ? error.message : String(error), + cause: error, + }); const rpcClientError = new RpcClientError.RpcClientError({ reason: new RpcClientError.RpcClientDefect({ message: normalized.message, @@ -415,7 +479,7 @@ export const makeAcpPatchedProtocol = ( method, ...(payload !== undefined ? { params: payload } : {}), })}\n`, - ).pipe(Effect.asVoid, Effect.mapError(AcpError.normalizeAcpError)); + ).pipe(Effect.asVoid); const sendRequest = (method: string, payload: unknown) => Effect.gen(function* () { @@ -470,11 +534,10 @@ function isProtocolError( ); } -function normalizeToRequestError(error: unknown): AcpError.AcpRequestError { - const normalized = AcpError.normalizeAcpError(error); - return Schema.is(AcpError.AcpRequestError)(normalized) - ? normalized - : AcpError.AcpRequestError.internalError(normalized.message); +function normalizeToRequestError(error: AcpError.AcpError): AcpError.AcpRequestError { + return Schema.is(AcpError.AcpRequestError)(error) + ? error + : AcpError.AcpRequestError.internalError(error.message); } function toRpcClientError(error: AcpError.AcpError): RpcClientError.RpcClientError { diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index 1a3f2269527..0113fb72aaf 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -14,6 +14,8 @@ Effect.gen(function* () { }); const handle = yield* spawner.spawn(command); const client = yield* AcpClient.fromChildProcess(handle, { + logIncoming: true, + logOutgoing: true, handlers: { requestPermission: () => Effect.succeed({ @@ -43,7 +45,13 @@ Effect.gen(function* () { cwd: process.cwd(), mcpServers: [], }); - yield* Effect.logInfo("created session", { sessionId: session.sessionId }); + + const update = yield* client.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "model", + value: "gpt-5.4[reasoning=xhigh,context=272k,fast=false]", + }); + yield* Effect.logInfo("updated model", update); const result = yield* client.prompt({ sessionId: session.sessionId, diff --git a/packages/effect-acp/test/fixtures/acp-mock-peer.ts b/packages/effect-acp/test/fixtures/acp-mock-peer.ts index ff8b61ea132..e55ed0f6ce9 100644 --- a/packages/effect-acp/test/fixtures/acp-mock-peer.ts +++ b/packages/effect-acp/test/fixtures/acp-mock-peer.ts @@ -15,6 +15,21 @@ function writeMessage(message: unknown) { process.stdout.write(`${JSON.stringify(message)}\n`); } +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ) { + return error.message; + } + return String(error); +} + function respond(id: number | string | null | undefined, result: unknown) { writeMessage({ jsonrpc: "2.0", @@ -157,7 +172,7 @@ async function handleRequest(message: { }); await requestClient("x/typed_request", { - message: "hello from typed request", + message: process.env.ACP_MOCK_BAD_TYPED_REQUEST === "1" ? 123 : "hello from typed request", }); notify("x/typed_notification", { @@ -216,7 +231,7 @@ rl.on("line", (line) => { if ("method" in message && "id" in message) { void handleRequest(message).catch((error) => { - respondError(message.id, -32603, error instanceof Error ? error.message : String(error)); + respondError(message.id, -32603, errorMessage(error)); }); return; } From 17871856cb82debca0f38c523e34b84bb58c6451 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 15:50:32 -0700 Subject: [PATCH 26/82] tidy up effect-acp --- .../src/provider/acp/CursorAcpExtension.ts | 9 +- packages/effect-acp/scripts/generate.ts | 8 ++ .../effect-acp/src/_generated/meta.gen.ts | 3 + .../effect-acp/src/_generated/schema.gen.ts | 3 + packages/effect-acp/src/child-process.ts | 24 ------ packages/effect-acp/src/client.ts | 16 +++- packages/effect-acp/src/rpc.ts | 82 ++++++++++--------- packages/effect-acp/src/terminal.ts | 4 - .../examples/cursor-acp-client.example.ts | 21 ++++- 9 files changed, 93 insertions(+), 77 deletions(-) delete mode 100644 packages/effect-acp/src/child-process.ts diff --git a/apps/server/src/provider/acp/CursorAcpExtension.ts b/apps/server/src/provider/acp/CursorAcpExtension.ts index 787261af979..ee16260ae3d 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.ts @@ -79,13 +79,8 @@ export function extractTodosAsPlan(params: typeof CursorUpdateTodosRequest.Type) }>; } { const plan = params.todos.flatMap((todo) => { - const step = - typeof todo.content === "string" - ? todo.content.trim() - : typeof todo.title === "string" - ? todo.title.trim() - : ""; - if (!step) { + const step = todo.content?.trim() ?? todo.title?.trim() ?? ""; + if (step === "") { return []; } const status: "pending" | "inProgress" | "completed" = diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index 09d43205dcd..2a4e6cea56d 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -219,7 +219,14 @@ const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: bo } } + const prelude = [ + `// This file is generated by the effect-acp package. Do not edit manually.`, + `// Current ACP schema release: ${CURRENT_SCHEMA_RELEASE}`, + "", + ]; + const schemaOutput = [ + ...prelude, 'import * as Schema from "effect/Schema";', "", [...generatedEntries.values()].join("\n\n"), @@ -227,6 +234,7 @@ const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: bo ].join("\n"); const metaOutput = [ + ...prelude, `export const AGENT_METHODS = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.agentMethods))(upstreamMeta.agentMethods)} as const;`, "", `export const CLIENT_METHODS = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.clientMethods))(upstreamMeta.clientMethods)} as const;`, diff --git a/packages/effect-acp/src/_generated/meta.gen.ts b/packages/effect-acp/src/_generated/meta.gen.ts index b1b719a2024..5d2dd3dd3dd 100644 --- a/packages/effect-acp/src/_generated/meta.gen.ts +++ b/packages/effect-acp/src/_generated/meta.gen.ts @@ -1,3 +1,6 @@ +// This file is generated by the effect-acp package. Do not edit manually. +// Current ACP schema release: v0.11.3 + export const AGENT_METHODS = { authenticate: "authenticate", initialize: "initialize", diff --git a/packages/effect-acp/src/_generated/schema.gen.ts b/packages/effect-acp/src/_generated/schema.gen.ts index e77163a9295..73fdc752364 100644 --- a/packages/effect-acp/src/_generated/schema.gen.ts +++ b/packages/effect-acp/src/_generated/schema.gen.ts @@ -1,3 +1,6 @@ +// This file is generated by the effect-acp package. Do not edit manually. +// Current ACP schema release: v0.11.3 + import * as Schema from "effect/Schema"; export type AuthEnvVar = { diff --git a/packages/effect-acp/src/child-process.ts b/packages/effect-acp/src/child-process.ts deleted file mode 100644 index abfd8468c65..00000000000 --- a/packages/effect-acp/src/child-process.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Sink from "effect/Sink"; -import * as Stdio from "effect/Stdio"; -import type { ChildProcessSpawner } from "effect/unstable/process"; - -const textEncoder = new TextEncoder(); - -export function makeStdioFromChildProcess( - handle: ChildProcessSpawner.ChildProcessHandle, -): Stdio.Stdio { - return Stdio.make({ - args: Effect.succeed([]), - stdin: handle.stdout, - stdout: () => - Sink.mapInput(handle.stdin, (chunk: string | Uint8Array) => - typeof chunk === "string" ? textEncoder.encode(chunk) : chunk, - ), - stderr: () => Sink.drain, - }); -} - -export const layerStdioFromChildProcess = (handle: ChildProcessSpawner.ChildProcessHandle) => - Layer.succeed(Stdio.Stdio, makeStdioFromChildProcess(handle)); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 360927f4db5..bda1a91a4b5 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -3,10 +3,11 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as Sink from "effect/Sink"; +import * as Stdio from "effect/Stdio"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; import * as RpcServer from "effect/unstable/rpc/RpcServer"; -import { makeStdioFromChildProcess } from "./child-process"; import * as AcpError from "./errors"; import * as AcpProtocol from "./protocol"; import * as AcpRpcs from "./rpc"; @@ -467,3 +468,16 @@ const runExtNotificationHandler = ( } return fallback ? fallback(method, params) : Effect.void; }; + +const textEncoder = new TextEncoder(); +function makeStdioFromChildProcess(handle: ChildProcessSpawner.ChildProcessHandle): Stdio.Stdio { + return Stdio.make({ + args: Effect.succeed([]), + stdin: handle.stdout, + stdout: () => + Sink.mapInput(handle.stdin, (chunk: string | Uint8Array) => + typeof chunk === "string" ? textEncoder.encode(chunk) : chunk, + ), + stderr: () => Sink.drain, + }); +} diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts index c3faa3847f2..b3b5af1a7c5 100644 --- a/packages/effect-acp/src/rpc.ts +++ b/packages/effect-acp/src/rpc.ts @@ -1,130 +1,139 @@ -import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import * as AcpSchema from "./_generated/schema.gen"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen"; -function makeAcpRpc< - const Tag extends string, - Payload extends Schema.Top | Schema.Struct.Fields, - Success extends Schema.Top, ->(tag: Tag, options: { readonly payload: Payload; readonly success: Success }) { - return Rpc.make(tag, { - payload: options.payload, - success: options.success, - error: AcpSchema.Error, - }); -} - -export const InitializeRpc = makeAcpRpc(AGENT_METHODS.initialize, { +export const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, { payload: AcpSchema.InitializeRequest, success: AcpSchema.InitializeResponse, + error: AcpSchema.Error, }); -export const AuthenticateRpc = makeAcpRpc(AGENT_METHODS.authenticate, { +export const AuthenticateRpc = Rpc.make(AGENT_METHODS.authenticate, { payload: AcpSchema.AuthenticateRequest, success: AcpSchema.AuthenticateResponse, + error: AcpSchema.Error, }); -export const LogoutRpc = makeAcpRpc(AGENT_METHODS.logout, { +export const LogoutRpc = Rpc.make(AGENT_METHODS.logout, { payload: AcpSchema.LogoutRequest, success: AcpSchema.LogoutResponse, + error: AcpSchema.Error, }); -export const NewSessionRpc = makeAcpRpc(AGENT_METHODS.session_new, { +export const NewSessionRpc = Rpc.make(AGENT_METHODS.session_new, { payload: AcpSchema.NewSessionRequest, success: AcpSchema.NewSessionResponse, + error: AcpSchema.Error, }); -export const LoadSessionRpc = makeAcpRpc(AGENT_METHODS.session_load, { +export const LoadSessionRpc = Rpc.make(AGENT_METHODS.session_load, { payload: AcpSchema.LoadSessionRequest, success: AcpSchema.LoadSessionResponse, + error: AcpSchema.Error, }); -export const ListSessionsRpc = makeAcpRpc(AGENT_METHODS.session_list, { +export const ListSessionsRpc = Rpc.make(AGENT_METHODS.session_list, { payload: AcpSchema.ListSessionsRequest, success: AcpSchema.ListSessionsResponse, + error: AcpSchema.Error, }); -export const ForkSessionRpc = makeAcpRpc(AGENT_METHODS.session_fork, { +export const ForkSessionRpc = Rpc.make(AGENT_METHODS.session_fork, { payload: AcpSchema.ForkSessionRequest, success: AcpSchema.ForkSessionResponse, + error: AcpSchema.Error, }); -export const ResumeSessionRpc = makeAcpRpc(AGENT_METHODS.session_resume, { +export const ResumeSessionRpc = Rpc.make(AGENT_METHODS.session_resume, { payload: AcpSchema.ResumeSessionRequest, success: AcpSchema.ResumeSessionResponse, + error: AcpSchema.Error, }); -export const CloseSessionRpc = makeAcpRpc(AGENT_METHODS.session_close, { +export const CloseSessionRpc = Rpc.make(AGENT_METHODS.session_close, { payload: AcpSchema.CloseSessionRequest, success: AcpSchema.CloseSessionResponse, + error: AcpSchema.Error, }); -export const SetSessionModeRpc = makeAcpRpc(AGENT_METHODS.session_set_mode, { +export const SetSessionModeRpc = Rpc.make(AGENT_METHODS.session_set_mode, { payload: AcpSchema.SetSessionModeRequest, success: AcpSchema.SetSessionModeResponse, + error: AcpSchema.Error, }); -export const PromptRpc = makeAcpRpc(AGENT_METHODS.session_prompt, { +export const PromptRpc = Rpc.make(AGENT_METHODS.session_prompt, { payload: AcpSchema.PromptRequest, success: AcpSchema.PromptResponse, + error: AcpSchema.Error, }); -export const SetSessionModelRpc = makeAcpRpc(AGENT_METHODS.session_set_model, { +export const SetSessionModelRpc = Rpc.make(AGENT_METHODS.session_set_model, { payload: AcpSchema.SetSessionModelRequest, success: AcpSchema.SetSessionModelResponse, + error: AcpSchema.Error, }); -export const SetSessionConfigOptionRpc = makeAcpRpc(AGENT_METHODS.session_set_config_option, { +export const SetSessionConfigOptionRpc = Rpc.make(AGENT_METHODS.session_set_config_option, { payload: AcpSchema.SetSessionConfigOptionRequest, success: AcpSchema.SetSessionConfigOptionResponse, + error: AcpSchema.Error, }); -export const ReadTextFileRpc = makeAcpRpc(CLIENT_METHODS.fs_read_text_file, { +export const ReadTextFileRpc = Rpc.make(CLIENT_METHODS.fs_read_text_file, { payload: AcpSchema.ReadTextFileRequest, success: AcpSchema.ReadTextFileResponse, + error: AcpSchema.Error, }); -export const WriteTextFileRpc = makeAcpRpc(CLIENT_METHODS.fs_write_text_file, { +export const WriteTextFileRpc = Rpc.make(CLIENT_METHODS.fs_write_text_file, { payload: AcpSchema.WriteTextFileRequest, success: AcpSchema.WriteTextFileResponse, + error: AcpSchema.Error, }); -export const RequestPermissionRpc = makeAcpRpc(CLIENT_METHODS.session_request_permission, { +export const RequestPermissionRpc = Rpc.make(CLIENT_METHODS.session_request_permission, { payload: AcpSchema.RequestPermissionRequest, success: AcpSchema.RequestPermissionResponse, + error: AcpSchema.Error, }); -export const ElicitationRpc = makeAcpRpc(CLIENT_METHODS.session_elicitation, { +export const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, { payload: AcpSchema.ElicitationRequest, success: AcpSchema.ElicitationResponse, + error: AcpSchema.Error, }); -export const CreateTerminalRpc = makeAcpRpc(CLIENT_METHODS.terminal_create, { +export const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { payload: AcpSchema.CreateTerminalRequest, success: AcpSchema.CreateTerminalResponse, + error: AcpSchema.Error, }); -export const TerminalOutputRpc = makeAcpRpc(CLIENT_METHODS.terminal_output, { +export const TerminalOutputRpc = Rpc.make(CLIENT_METHODS.terminal_output, { payload: AcpSchema.TerminalOutputRequest, success: AcpSchema.TerminalOutputResponse, + error: AcpSchema.Error, }); -export const ReleaseTerminalRpc = makeAcpRpc(CLIENT_METHODS.terminal_release, { +export const ReleaseTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_release, { payload: AcpSchema.ReleaseTerminalRequest, success: AcpSchema.ReleaseTerminalResponse, + error: AcpSchema.Error, }); -export const WaitForTerminalExitRpc = makeAcpRpc(CLIENT_METHODS.terminal_wait_for_exit, { +export const WaitForTerminalExitRpc = Rpc.make(CLIENT_METHODS.terminal_wait_for_exit, { payload: AcpSchema.WaitForTerminalExitRequest, success: AcpSchema.WaitForTerminalExitResponse, + error: AcpSchema.Error, }); -export const KillTerminalRpc = makeAcpRpc(CLIENT_METHODS.terminal_kill, { +export const KillTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_kill, { payload: AcpSchema.KillTerminalRequest, success: AcpSchema.KillTerminalResponse, + error: AcpSchema.Error, }); export const AgentRpcs = RpcGroup.make( @@ -154,6 +163,3 @@ export const ClientRpcs = RpcGroup.make( WaitForTerminalExitRpc, KillTerminalRpc, ); - -export const ClientRequestMethodSet = new Set(ClientRpcs.requests.keys()); -export const AgentRequestMethodSet = new Set(AgentRpcs.requests.keys()); diff --git a/packages/effect-acp/src/terminal.ts b/packages/effect-acp/src/terminal.ts index 6fad3d08252..c89c02bfc5a 100644 --- a/packages/effect-acp/src/terminal.ts +++ b/packages/effect-acp/src/terminal.ts @@ -43,7 +43,3 @@ export function makeTerminal(options: MakeTerminalOptions): AcpTerminal { release: options.release, }; } - -export const TerminalHandle = { - make: makeTerminal, -}; diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index 0113fb72aaf..21b47d0bc7c 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -46,12 +46,27 @@ Effect.gen(function* () { mcpServers: [], }); - const update = yield* client.setSessionConfigOption({ + yield* client.setSessionConfigOption({ sessionId: session.sessionId, configId: "model", - value: "gpt-5.4[reasoning=xhigh,context=272k,fast=false]", + value: "gpt-5.4[reasoning=medium,context=272k,fast=false]", }); - yield* Effect.logInfo("updated model", update); + // yield* client.setSessionConfigOption({ + // sessionId: session.sessionId, + // configId: "reasoning_effort", + // value: "high", + // }); + // yield* client.setSessionConfigOption({ + // sessionId: session.sessionId, + // configId: "context_size", + // value: "1m", + // }); + // yield* client.setSessionConfigOption({ + // sessionId: session.sessionId, + // configId: "fast_mode", + // type: "boolean", + // value: true, + // }); const result = yield* client.prompt({ sessionId: session.sessionId, From ba6604b3474d25d5db09218c17aee38f57bb2030 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 15:51:14 -0700 Subject: [PATCH 27/82] error on fmt --- packages/effect-acp/scripts/generate.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index 2a4e6cea56d..9628f716e89 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -252,7 +252,9 @@ const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: bo yield* Effect.service(ChildProcessSpawner.ChildProcessSpawner).pipe( Effect.flatMap((spawner) => spawner.spawn(ChildProcess.make("bun", ["oxfmt", generatedDir]))), Effect.flatMap((child) => child.exitCode), - Effect.tap(() => Effect.log("Formatted generated files")), + Effect.tap((code) => + code === 0 ? Effect.void : Effect.fail(new Error(`oxfmt failed with exit code ${code}`)), + ), ); }); From 1f7a48a1ea28e6ca93a21b777a98c6c3325e5f90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 15:54:42 -0700 Subject: [PATCH 28/82] small nit --- .../src/components/chat/CompactComposerControlsMenu.browser.tsx | 2 +- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 658ee8ae7b7..0dfa88530cc 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_MODEL_BY_PROVIDER, type ModelSelection, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts"; import "../../index.css"; import { page } from "vitest/browser"; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e0ccde846ec..127be955ea2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -901,7 +901,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {

-

{displayText}

+

{displayText}

From 9476dd2cfc8fbe34d6f597be3e07bf59de2c8ae1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 16:47:56 -0700 Subject: [PATCH 29/82] Handle cancelled Cursor ACP turns and simplify model ids - propagate session cancel into pending ACP approvals - emit cancelled turn state when interruptions resolve as cancelled - stop adding default ACP config traits for built-in Cursor models --- apps/server/scripts/acp-mock-agent.ts | 20 +- .../src/provider/Layers/CursorAdapter.test.ts | 182 ++++++------ .../src/provider/Layers/CursorAdapter.ts | 265 +++++++++++++++--- .../provider/Layers/CursorProvider.test.ts | 23 +- .../src/provider/Layers/CursorProvider.ts | 39 ++- .../provider/Layers/ProviderRegistry.test.ts | 7 - .../provider/acp/AcpAdapterSupport.test.ts | 106 +------ .../src/provider/acp/AcpAdapterSupport.ts | 185 ------------ .../src/provider/acp/AcpRuntimeModel.test.ts | 6 +- .../src/provider/acp/AcpRuntimeModel.ts | 56 +--- .../src/provider/acp/CursorAcpExtension.ts | 4 + packages/contracts/src/model.ts | 8 - packages/effect-acp/scripts/generate.ts | 12 +- packages/effect-acp/src/client.ts | 4 - packages/effect-acp/src/protocol.test.ts | 15 +- packages/effect-acp/src/protocol.ts | 26 -- 16 files changed, 423 insertions(+), 535 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 64e502478dd..3b6a596399b 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -54,6 +54,7 @@ const availableModes = [ }, ]; const pendingPermissionRequests = new Map(); +const cancelledPromptRequestIds = new Set(); function send(obj: unknown) { process.stdout.write(`${JSON.stringify(obj)}\n`); @@ -102,6 +103,17 @@ rl.on("line", (line) => { if (method === undefined && id !== undefined && pendingPermissionRequests.has(id)) { const pending = pendingPermissionRequests.get(id); pendingPermissionRequests.delete(id); + const outcome = (rpcMessage.params?.outcome ?? + (msg as { result?: { outcome?: unknown } }).result?.outcome) as + | { outcome?: unknown } + | undefined; + const cancelled = + cancelledPromptRequestIds.has(pending.promptRequestId) || + (typeof outcome === "object" && + outcome !== null && + "outcome" in outcome && + outcome.outcome === "cancelled"); + cancelledPromptRequestIds.delete(pending.promptRequestId); sendSessionUpdate( { sessionUpdate: "tool_call_update", @@ -127,7 +139,7 @@ rl.on("line", (line) => { send({ jsonrpc: "2.0", id: pending.promptRequestId, - result: { stopReason: "end_turn" }, + result: { stopReason: cancelled ? "cancelled" : "end_turn" }, }); return; } @@ -331,6 +343,12 @@ rl.on("line", (line) => { } if (method === AGENT_METHODS.session_cancel) { + const cancelledSessionId = rpcMessage.params?.sessionId; + for (const pending of pendingPermissionRequests.values()) { + if (pending.sessionId === cancelledSessionId) { + cancelledPromptRequestIds.add(pending.promptRequestId); + } + } if (id !== undefined) { send({ jsonrpc: "2.0", id, result: null }); } diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 94e1b26274e..0bcc62d415f 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -13,7 +13,6 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { makeCursorAdapterLive } from "./CursorAdapter.ts"; -import { resolveCursorAcpModelId } from "./CursorProvider.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); @@ -77,7 +76,11 @@ async function readJsonLines(filePath: string) { const cursorAdapterTestLayer = it.layer( makeCursorAdapterLive().pipe( Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-adapter-test-", + }), + ), Layer.provideMerge(NodeServices.layer), ), ); @@ -167,86 +170,6 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); - it.effect("selects the Cursor model via ACP config updates instead of CLI argv", () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.makeUnsafe("cursor-model-probe"); - const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const argvLogPath = path.join(tempDir, "argv.txt"); - yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); - const wrapperPath = yield* Effect.promise(() => - makeProbeWrapper(requestLogPath, argvLogPath), - ); - yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - - const dispatchedModel = resolveCursorAcpModelId("composer-2", { fastMode: true }); - const session = yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, - }); - - assert.equal(session.model, "composer-2"); - - yield* adapter.sendTurn({ - threadId, - input: "probe model selection", - attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, - }); - yield* adapter.stopSession(threadId); - - const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); - assert.deepStrictEqual(argvRuns, [["acp"]]); - - const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const methods = requests - .map((entry) => entry.method) - .filter((method): method is string => typeof method === "string"); - assert.includeMembers(methods, [ - "initialize", - "authenticate", - "session/new", - "session/set_mode", - "session/prompt", - ]); - - for (const request of requests) { - const params = request.params; - if (params && typeof params === "object" && !Array.isArray(params)) { - assert.isFalse(Object.prototype.hasOwnProperty.call(params, "model")); - } - } - - const setConfigRequests = requests.filter( - (entry) => entry.method === "session/set_config_option", - ); - assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); - assert.equal( - (setConfigRequests[setConfigRequests.length - 1]?.params as Record)?.value, - dispatchedModel, - ); - - const promptRequest = requests.find((entry) => entry.method === "session/prompt"); - assert.isDefined(promptRequest); - assert.deepStrictEqual( - Object.keys((promptRequest?.params as Record) ?? {}).toSorted(), - ["prompt", "sessionId"], - ); - - const modeRequest = requests.find((entry) => entry.method === "session/set_mode"); - assert.isDefined(modeRequest); - assert.deepStrictEqual(modeRequest?.params, { - sessionId: "mock-session-1", - modeId: "code", - }); - }), - ); - it.effect("maps app plan mode onto the ACP plan session mode", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; @@ -436,6 +359,96 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ), ); + it.effect("cancels pending ACP approvals and marks the turn cancelled when interrupted", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-cancel-probe"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const requestResolvedReady = yield* Deferred.make(); + const turnCompletedReady = yield* Deferred.make(); + let interrupted = false; + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "request.opened" && !interrupted) { + interrupted = true; + yield* adapter.interruptTurn(threadId); + return; + } + if (event.type === "request.resolved") { + yield* Deferred.succeed(requestResolvedReady, event).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompletedReady, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "cancel this turn", + attachments: [], + }) + .pipe(Effect.forkChild); + + const requestResolved = yield* Deferred.await(requestResolvedReady); + const turnCompleted = yield* Deferred.await(turnCompletedReady); + yield* Fiber.join(sendTurnFiber); + yield* Fiber.interrupt(runtimeEventsFiber); + + assert.equal(requestResolved.type, "request.resolved"); + if (requestResolved.type === "request.resolved") { + assert.equal(requestResolved.payload.decision, "cancel"); + } + + assert.equal(turnCompleted.type, "turn.completed"); + if (turnCompleted.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "cancelled"); + assert.equal(turnCompleted.payload.stopReason, "cancelled"); + } + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); + assert.isTrue( + requests.some( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "outcome" in entry.result.outcome && + entry.result.outcome.outcome === "cancelled", + ), + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("switches model in-session via session/set_config_option", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; @@ -480,10 +493,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { (entry) => entry.method === "session/set_config_option", ); assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); - assert.equal( - (setConfigRequests[0]?.params as Record)?.value, - "composer-2[fast=false]", - ); + assert.equal((setConfigRequests[0]?.params as Record)?.value, "composer-2"); const lastSetConfig = setConfigRequests[setConfigRequests.length - 1]; assert.equal( (lastSetConfig?.params as Record)?.value, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index fcc1da70882..0d803331814 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -8,6 +8,8 @@ import * as nodePath from "node:path"; import { ApprovalRequestId, EventId, + RuntimeItemId, + type ToolLifecycleItemType, type ProviderApprovalDecision, type ProviderInteractionMode, type ProviderRuntimeEvent, @@ -43,18 +45,11 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { makeAcpSessionRuntime, type AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; -import { - acpPermissionOutcome, - makeAcpContentDeltaEvent, - makeAcpPlanUpdatedEvent, - makeAcpRequestOpenedEvent, - makeAcpRequestResolvedEvent, - makeAcpToolCallEvent, - mapAcpToAdapterError, -} from "../acp/AcpAdapterSupport.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; import { type AcpSessionMode, type AcpSessionModeState, + type AcpToolCallState, parsePermissionRequest, } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; @@ -75,6 +70,7 @@ const CURSOR_RESUME_VERSION = 1 as const; const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; const ACP_APPROVAL_MODE_ALIASES = ["ask"]; +type AcpRawSource = "acp.jsonrpc" | "acp.cursor.extension"; export interface CursorAdapterLiveOptions { readonly nativeEventLogPath?: string; @@ -83,11 +79,7 @@ export interface CursorAdapterLiveOptions { interface PendingApproval { readonly decision: Deferred.Deferred; - readonly requestType: - | "exec_command_approval" - | "file_read_approval" - | "file_change_approval" - | "unknown"; + readonly kind: string | "unknown"; } interface PendingUserInput { @@ -107,6 +99,19 @@ interface CursorSessionContext { stopped: boolean; } +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -118,13 +123,6 @@ function parseCursorResume(raw: unknown): { sessionId: string } | undefined { return { sessionId: raw.sessionId.trim() }; } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - function normalizeModeSearchText(mode: AcpSessionMode): string { return [mode.id, mode.name, mode.description] .filter((value): value is string => typeof value === "string" && value.length > 0) @@ -162,6 +160,55 @@ function isPlanMode(mode: AcpSessionMode): boolean { return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; } +function canonicalRequestTypeFromAcpKind( + kind: string | "unknown", +): "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" { + switch (kind) { + case "execute": + return "exec_command_approval"; + case "read": + return "file_read_approval"; + case "edit": + case "delete": + case "move": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { + switch (kind) { + case "execute": + return "command_execution"; + case "edit": + case "delete": + case "move": + return "file_change"; + case "search": + case "fetch": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + +function runtimeItemStatusFromAcpToolStatus( + status: AcpToolCallState["status"], +): "inProgress" | "completed" | "failed" | undefined { + switch (status) { + case "pending": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return undefined; + } +} + function resolveRequestedModeId(input: { readonly interactionMode: ProviderInteractionMode | undefined; readonly runtimeMode: RuntimeMode; @@ -217,6 +264,140 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const offerRuntimeEvent = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + const makeRequestOpenedEvent = (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly requestId: RuntimeRequestId; + readonly kind: string | "unknown"; + readonly detail: string; + readonly args: unknown; + readonly source: AcpRawSource; + readonly method: string; + readonly rawPayload: unknown; + }): Effect.Effect => + Effect.map(makeEventStamp(), (stamp) => ({ + type: "request.opened", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + requestId: input.requestId, + payload: { + requestType: canonicalRequestTypeFromAcpKind(input.kind), + detail: input.detail, + args: input.args, + }, + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + })); + + const makeRequestResolvedEvent = (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly requestId: RuntimeRequestId; + readonly kind: string | "unknown"; + readonly decision: ProviderApprovalDecision; + }): Effect.Effect => + Effect.map(makeEventStamp(), (stamp) => ({ + type: "request.resolved", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + requestId: input.requestId, + payload: { + requestType: canonicalRequestTypeFromAcpKind(input.kind), + decision: input.decision, + }, + })); + + const makePlanUpdatedEvent = (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }; + readonly source: AcpRawSource; + readonly method: string; + readonly rawPayload: unknown; + }): Effect.Effect => + Effect.map(makeEventStamp(), (stamp) => ({ + type: "turn.plan.updated", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + payload: input.payload, + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + })); + + const makeToolCallRuntimeEvent = (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly toolCall: AcpToolCallState; + readonly rawPayload: unknown; + }): Effect.Effect => + Effect.map(makeEventStamp(), (stamp) => { + const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status); + return { + type: + input.toolCall.status === "completed" || input.toolCall.status === "failed" + ? "item.completed" + : "item.updated", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + itemId: RuntimeItemId.makeUnsafe(input.toolCall.toolCallId), + payload: { + itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind), + ...(runtimeStatus ? { status: runtimeStatus } : {}), + ...(input.toolCall.title ? { title: input.toolCall.title } : {}), + ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), + ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + } satisfies ProviderRuntimeEvent; + }); + + const makeContentDeltaEvent = (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly text: string; + readonly rawPayload: unknown; + }): Effect.Effect => + Effect.map(makeEventStamp(), (stamp) => ({ + type: "content.delta", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + payload: { + streamKind: "assistant_text", + delta: input.text, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + })); + const logNative = ( threadId: ThreadId, method: string, @@ -263,9 +444,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { } ctx.lastPlanFingerprint = fingerprint; yield* offerRuntimeEvent( - makeAcpPlanUpdatedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, + yield* makePlanUpdatedEvent({ threadId: ctx.threadId, turnId: ctx.activeTurnId, payload, @@ -472,16 +651,14 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const decision = yield* Deferred.make(); pendingApprovals.set(requestId, { decision, - requestType: permissionRequest.requestType, + kind: permissionRequest.kind, }); yield* offerRuntimeEvent( - makeAcpRequestOpenedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, + yield* makeRequestOpenedEvent({ threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, - requestType: permissionRequest.requestType, + kind: permissionRequest.kind, detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000), args: params, source: "acp.jsonrpc", @@ -492,21 +669,22 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const resolved = yield* Deferred.await(decision); pendingApprovals.delete(requestId); yield* offerRuntimeEvent( - makeAcpRequestResolvedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, + yield* makeRequestResolvedEvent({ threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, - requestType: permissionRequest.requestType, + kind: permissionRequest.kind, decision: resolved, }), ); return { - outcome: { - outcome: "selected" as const, - optionId: acpPermissionOutcome(resolved), - }, + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, }; }), }, @@ -571,9 +749,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { case "ToolCallUpdated": yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); yield* offerRuntimeEvent( - makeAcpToolCallEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, + yield* makeToolCallRuntimeEvent({ threadId: ctx.threadId, turnId: ctx.activeTurnId, toolCall: event.toolCall, @@ -584,9 +760,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { case "ContentDelta": yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); yield* offerRuntimeEvent( - makeAcpContentDeltaEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, + yield* makeContentDeltaEvent({ threadId: ctx.threadId, turnId: ctx.activeTurnId, text: event.text, @@ -702,7 +876,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", - detail: toMessage(cause, "Failed to read attachment."), + detail: cause.message, cause, }), ), @@ -748,7 +922,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { threadId: input.threadId, turnId, payload: { - state: "completed", + state: result.stopReason === "cancelled" ? "cancelled" : "completed", stopReason: result.stopReason ?? null, }, }); @@ -763,6 +937,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); yield* Effect.ignore( ctx.acp.cancel.pipe( Effect.mapError((error) => diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 331a9090fed..01bed42f7b5 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -7,33 +7,30 @@ import { } from "./CursorProvider.ts"; describe("resolveCursorAcpModelId", () => { - it("builds bracket notation from canonical base slugs and capabilities", () => { + it("emits ACP model ids that match explicit Cursor ACP config values", () => { expect(resolveCursorAcpModelId("composer-2", { fastMode: true })).toBe("composer-2[fast=true]"); - expect(resolveCursorAcpModelId("gpt-5.4", undefined)).toBe( - "gpt-5.4[reasoning=medium,context=272k,fast=false]", - ); + expect(resolveCursorAcpModelId("gpt-5.4", undefined)).toBe("gpt-5.4"); expect( resolveCursorAcpModelId("claude-opus-4-6", { reasoning: "high", thinking: true, contextWindow: "1m", }), - ).toBe("claude-opus-4-6[effort=high,thinking=true,context=1m,fast=false]"); + ).toBe("claude-opus-4-6[effort=high,thinking=true,context=1m]"); + expect(resolveCursorAcpModelId("gpt-5.3-codex", undefined)).toBe( + "gpt-5.3-codex[reasoning=medium,fast=false]", + ); }); it("maps legacy cursor aliases onto the canonical base slug", () => { - expect(resolveCursorAcpModelId("gpt-5.4-1m", undefined)).toBe( - "gpt-5.4[reasoning=medium,context=272k,fast=false]", - ); - expect(resolveCursorAcpModelId("auto", undefined)).toBe("default[]"); - expect(resolveCursorAcpModelId("claude-4.6-opus", undefined)).toBe( - "claude-opus-4-6[effort=high,thinking=true,context=200k,fast=false]", - ); + expect(resolveCursorAcpModelId("gpt-5.4-1m", undefined)).toBe("gpt-5.4"); + expect(resolveCursorAcpModelId("auto", undefined)).toBe("auto[]"); + expect(resolveCursorAcpModelId("claude-4.6-opus", undefined)).toBe("claude-opus-4-6"); }); it("passes custom models through unchanged", () => { expect(resolveCursorAcpModelId("custom/internal-model", undefined)).toBe( - "custom/internal-model[]", + "custom/internal-model", ); }); }); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 76ef30d6b45..51e3928a59d 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -196,37 +196,48 @@ export function resolveCursorAcpModelId( model: string | null | undefined, modelOptions: CursorModelOptions | null | undefined, ): string { - const slug = normalizeModelSlug(model, "cursor") ?? "default"; + const slug = normalizeModelSlug(model, "cursor") ?? "auto"; if (slug.includes("[") && slug.endsWith("]")) { return slug; } const caps = getCursorModelCapabilities(slug); const isBuiltIn = BUILT_IN_MODELS.some((candidate) => candidate.slug === slug); if (!isBuiltIn) { - return `${slug}[]`; + return slug; } const traits: string[] = []; - const reasoning = resolveEffort(caps, modelOptions?.reasoning); - if (reasoning) { - traits.push(`${slug.startsWith("claude-") ? "effort" : "reasoning"}=${reasoning}`); + + if (slug === "gpt-5.3-codex") { + const reasoning = resolveEffort(caps, modelOptions?.reasoning) ?? "medium"; + traits.push(`reasoning=${reasoning}`); + traits.push(`fast=${modelOptions?.fastMode === true}`); + return `${slug}[${traits.join(",")}]`; } - const thinking = caps.supportsThinkingToggle ? (modelOptions?.thinking ?? true) : undefined; - if (thinking !== undefined) { - traits.push(`thinking=${thinking}`); + if (caps.supportsFastMode && modelOptions?.fastMode === true) { + traits.push("fast=true"); } - const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - if (contextWindow) { - traits.push(`context=${contextWindow}`); + if (modelOptions?.reasoning !== undefined) { + const reasoning = resolveEffort(caps, modelOptions.reasoning); + if (reasoning) { + traits.push(`${slug.startsWith("claude-") ? "effort" : "reasoning"}=${reasoning}`); + } } - if (caps.supportsFastMode) { - traits.push(`fast=${modelOptions?.fastMode === true}`); + if (caps.supportsThinkingToggle && modelOptions?.thinking !== undefined) { + traits.push(`thinking=${modelOptions.thinking}`); + } + + if (modelOptions?.contextWindow !== undefined) { + const contextWindow = resolveContextWindow(caps, modelOptions.contextWindow); + if (contextWindow) { + traits.push(`context=${contextWindow}`); + } } - return `${slug}[${traits.join(",")}]`; + return traits.length > 0 ? `${slug}[${traits.join(",")}]` : slug; } /** diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ec61079618a..bed25977d6b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -403,13 +403,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( if (joined === "login status") { return { stdout: "Logged in\n", stderr: "", code: 0 }; } - if (joined === "about") { - return { - stdout: "Version: 1.0.0\nUser Email: user@example.com\n", - stderr: "", - code: 0, - }; - } throw new Error(`Unexpected args: ${joined}`); }), ), diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts index b6c9aec770a..7457713e0af 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -1,13 +1,7 @@ -import { RuntimeRequestId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; +import * as EffectAcpErrors from "effect-acp/errors"; -import { - acpPermissionOutcome, - makeAcpContentDeltaEvent, - makeAcpPlanUpdatedEvent, - makeAcpRequestOpenedEvent, - makeAcpToolCallEvent, -} from "./AcpAdapterSupport.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts"; describe("AcpAdapterSupport", () => { it("maps ACP approval decisions to permission outcomes", () => { @@ -16,92 +10,18 @@ describe("AcpAdapterSupport", () => { expect(acpPermissionOutcome("decline")).toBe("reject-once"); }); - it("builds shared ACP-backed runtime events", () => { - const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; - const turnId = TurnId.makeUnsafe("turn-1"); - - expect( - makeAcpRequestOpenedEvent({ - stamp, - provider: "cursor", - threadId: "thread-1" as never, - turnId, - requestId: RuntimeRequestId.makeUnsafe("request-1"), - requestType: "exec_command_approval", - detail: "cat package.json", - args: { command: ["cat", "package.json"] }, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "request.opened", - provider: "cursor", - turnId, - payload: { - requestType: "exec_command_approval", - detail: "cat package.json", - }, - }); - - expect( - makeAcpPlanUpdatedEvent({ - stamp, - provider: "cursor", - threadId: "thread-1" as never, - turnId, - payload: { - plan: [{ step: "Inspect state", status: "inProgress" }], - }, - source: "acp.cursor.extension", - method: "cursor/update_todos", - rawPayload: { todos: [] }, + it("maps ACP request errors to provider adapter request errors", () => { + const error = mapAcpToAdapterError( + "cursor", + "thread-1" as never, + "session/prompt", + new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: "Invalid params", }), - ).toMatchObject({ - type: "turn.plan.updated", - raw: { - method: "cursor/update_todos", - }, - }); + ); - expect( - makeAcpToolCallEvent({ - stamp, - provider: "cursor", - threadId: "thread-1" as never, - turnId, - toolCall: { - toolCallId: "tool-1", - itemType: "command_execution", - status: "completed", - title: "Terminal", - detail: "bun run test", - data: { command: "bun run test" }, - }, - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "item.completed", - payload: { - itemType: "command_execution", - status: "completed", - }, - }); - - expect( - makeAcpContentDeltaEvent({ - stamp, - provider: "cursor", - threadId: "thread-1" as never, - turnId, - text: "hello", - rawPayload: { sessionId: "session-1" }, - }), - ).toMatchObject({ - type: "content.delta", - payload: { - delta: "hello", - }, - }); + expect(error._tag).toBe("ProviderAdapterRequestError"); + expect(error.message).toContain("Invalid params"); }); }); diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index 7f5664a8a6a..914bb7e8c31 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -1,12 +1,7 @@ import { - RuntimeItemId, - type EventId, type ProviderApprovalDecision, type ProviderKind, - type ProviderRuntimeEvent, type ThreadId, - type TurnId, - type RuntimeRequestId, } from "@t3tools/contracts"; import { Schema } from "effect"; import * as EffectAcpErrors from "effect-acp/errors"; @@ -16,30 +11,6 @@ import { ProviderAdapterSessionClosedError, type ProviderAdapterError, } from "../Errors.ts"; -import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; - -export type AcpAdapterRawSource = "acp.jsonrpc" | `acp.${string}.extension`; - -export interface AcpEventStamp { - readonly eventId: EventId; - readonly createdAt: string; -} - -function runtimeItemStatusFromToolCallStatus( - status: "pending" | "inProgress" | "completed" | "failed" | undefined, -): "inProgress" | "completed" | "failed" | undefined { - switch (status) { - case "pending": - case "inProgress": - return "inProgress"; - case "completed": - return "completed"; - case "failed": - return "failed"; - default: - return undefined; - } -} export function mapAcpToAdapterError( provider: ProviderKind, @@ -77,163 +48,7 @@ export function acpPermissionOutcome(decision: ProviderApprovalDecision): string case "accept": return "allow-once"; case "decline": - case "cancel": default: return "reject-once"; } } - -export function makeAcpRequestOpenedEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly requestId: RuntimeRequestId; - readonly requestType: - | "exec_command_approval" - | "file_read_approval" - | "file_change_approval" - | "unknown"; - readonly detail: string; - readonly args: unknown; - readonly source: AcpAdapterRawSource; - readonly method: string; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - return { - type: "request.opened", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - requestId: input.requestId, - payload: { - requestType: input.requestType, - detail: input.detail, - args: input.args, - }, - raw: { - source: input.source, - method: input.method, - payload: input.rawPayload, - }, - }; -} - -export function makeAcpRequestResolvedEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly requestId: RuntimeRequestId; - readonly requestType: - | "exec_command_approval" - | "file_read_approval" - | "file_change_approval" - | "unknown"; - readonly decision: ProviderApprovalDecision; -}): ProviderRuntimeEvent { - return { - type: "request.resolved", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - requestId: input.requestId, - payload: { - requestType: input.requestType, - decision: input.decision, - }, - }; -} - -export function makeAcpPlanUpdatedEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly payload: { - readonly explanation?: string | null; - readonly plan: ReadonlyArray<{ - readonly step: string; - readonly status: "pending" | "inProgress" | "completed"; - }>; - }; - readonly source: AcpAdapterRawSource; - readonly method: string; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - return { - type: "turn.plan.updated", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - payload: input.payload, - raw: { - source: input.source, - method: input.method, - payload: input.rawPayload, - }, - }; -} - -export function makeAcpToolCallEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly toolCall: AcpToolCallState; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - const runtimeStatus = runtimeItemStatusFromToolCallStatus(input.toolCall.status); - return { - type: - input.toolCall.status === "completed" || input.toolCall.status === "failed" - ? "item.completed" - : "item.updated", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - itemId: RuntimeItemId.makeUnsafe(input.toolCall.toolCallId), - payload: { - itemType: input.toolCall.itemType, - ...(runtimeStatus ? { status: runtimeStatus } : {}), - ...(input.toolCall.title ? { title: input.toolCall.title } : {}), - ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), - ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), - }, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: input.rawPayload, - }, - }; -} - -export function makeAcpContentDeltaEvent(input: { - readonly stamp: AcpEventStamp; - readonly provider: ProviderKind; - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly text: string; - readonly rawPayload: unknown; -}): ProviderRuntimeEvent { - return { - type: "content.delta", - ...input.stamp, - provider: input.provider, - threadId: input.threadId, - turnId: input.turnId, - payload: { - streamKind: "assistant_text", - delta: input.text, - }, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: input.rawPayload, - }, - }; -} diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index c7a5d2e0d63..5d1cc5eca52 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -89,7 +89,7 @@ describe("AcpRuntimeModel", () => { _tag: "ToolCallUpdated", toolCall: { toolCallId: "tool-1", - itemType: "command_execution", + kind: "execute", title: "Terminal", status: "pending", command: "bun run typecheck", @@ -253,11 +253,11 @@ describe("AcpRuntimeModel", () => { }); expect(request).toMatchObject({ - requestType: "exec_command_approval", + kind: "execute", detail: "cat package.json", toolCall: { toolCallId: "tool-1", - itemType: "command_execution", + kind: "execute", status: "pending", command: "cat package.json", }, diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 7755986a48c..950fa64e873 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -1,4 +1,3 @@ -import type { ToolLifecycleItemType } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; function isRecord(value: unknown): value is Record { @@ -18,7 +17,7 @@ export interface AcpSessionModeState { export interface AcpToolCallState { readonly toolCallId: string; - readonly itemType: ToolLifecycleItemType; + readonly kind?: string; readonly title?: string; readonly status?: "pending" | "inProgress" | "completed" | "failed"; readonly command?: string; @@ -35,11 +34,7 @@ export interface AcpPlanUpdate { } export interface AcpPermissionRequest { - readonly requestType: - | "exec_command_approval" - | "file_read_approval" - | "file_change_approval" - | "unknown"; + readonly kind: string | "unknown"; readonly detail?: string; readonly toolCall?: AcpToolCallState; } @@ -231,37 +226,8 @@ function extractTextContentFromToolCallContent( return chunks.length > 0 ? chunks.join("\n") : undefined; } -function toolLifecycleItemTypeFromKind(kind: unknown): ToolLifecycleItemType { - switch (kind) { - case "execute": - return "command_execution"; - case "edit": - case "delete": - case "move": - return "file_change"; - case "search": - case "fetch": - return "web_search"; - default: - return "dynamic_tool_call"; - } -} - -function requestTypeFromToolKind( - kind: unknown, -): "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" { - switch (kind) { - case "execute": - return "exec_command_approval"; - case "read": - return "file_read_approval"; - case "edit": - case "delete": - case "move": - return "file_change_approval"; - default: - return "unknown"; - } +function normalizeToolKind(kind: unknown): string | undefined { + return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; } function makeToolCallState( @@ -292,8 +258,9 @@ function makeToolCallState( : undefined; const detail = command ?? normalizedTitle ?? textContent; const data: Record = { toolCallId }; - if (input.kind) { - data.kind = input.kind; + const kind = normalizeToolKind(input.kind); + if (kind) { + data.kind = kind; } if (command) { data.command = command; @@ -313,7 +280,7 @@ function makeToolCallState( const status = normalizeToolCallStatus(input.status, options?.fallbackStatus); return { toolCallId, - itemType: toolLifecycleItemTypeFromKind(input.kind), + ...(kind ? { kind } : {}), ...(title ? { title } : {}), ...(status ? { status } : {}), ...(command ? { command } : {}), @@ -348,13 +315,14 @@ export function mergeToolCallState( next: AcpToolCallState, ): AcpToolCallState { const nextKind = typeof next.data.kind === "string" ? next.data.kind : undefined; + const kind = nextKind ?? previous?.kind; const title = next.title ?? previous?.title; const status = next.status ?? previous?.status; const command = next.command ?? previous?.command; const detail = next.detail ?? previous?.detail; return { toolCallId: next.toolCallId, - itemType: nextKind !== undefined ? next.itemType : (previous?.itemType ?? next.itemType), + ...(kind ? { kind } : {}), ...(title ? { title } : {}), ...(status ? { status } : {}), ...(command ? { command } : {}), @@ -382,14 +350,14 @@ export function parsePermissionRequest( }, { fallbackStatus: "pending" }, ); - const requestType = requestTypeFromToolKind(params.toolCall.kind); + const kind = normalizeToolKind(params.toolCall.kind) ?? "unknown"; const detail = toolCall?.command ?? toolCall?.title ?? toolCall?.detail ?? (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); return { - requestType, + kind, ...(detail ? { detail } : {}), ...(toolCall ? { toolCall } : {}), }; diff --git a/apps/server/src/provider/acp/CursorAcpExtension.ts b/apps/server/src/provider/acp/CursorAcpExtension.ts index ee16260ae3d..dff65535c9d 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.ts @@ -1,3 +1,7 @@ +/** + * Public Docs: https://cursor.com/docs/cli/acp#cursor-extension-methods + * Additional reference provided by the Cursor team: https://anysphere.enterprise.slack.com/files/U068SSJE141/F0APT1HSZRP/cursor-acp-extension-method-schemas.md + */ import type { UserInputQuestion } from "@t3tools/contracts"; import { Schema } from "effect"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 52c174498a5..ba8752201ef 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -104,17 +104,9 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record spawner.spawn(ChildProcess.make("bun", ["oxfmt", generatedDir]))), Effect.flatMap((child) => child.exitCode), Effect.tap((code) => - code === 0 ? Effect.void : Effect.fail(new Error(`oxfmt failed with exit code ${code}`)), + code === 0 + ? Effect.void + : Effect.fail({ + _tag: "GenerateCommandError", + message: `oxfmt failed with exit code ${code}`, + }), ), ); }); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index bda1a91a4b5..72025a5a815 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -303,10 +303,6 @@ export const fromChildProcess = Effect.fnUntraced(function* ( notification.method, notification.params, ); - case "SessionCancel": - return handlers.extNotification - ? handlers.extNotification(notification.method, notification.params) - : Effect.void; } }, ...(handlers.extRequest || handlers.extRequests diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 390364c8592..7c8e567275a 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Sink from "effect/Sink"; import * as Stdio from "effect/Stdio"; @@ -13,6 +14,10 @@ import * as AcpProtocol from "./protocol"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); +const UnknownJson = Schema.UnknownFromJsonString; + +const encodeJson = Schema.encodeSync(UnknownJson); +const decodeJson = Schema.decodeUnknownSync(UnknownJson); function makeInMemoryStdio() { return Effect.gen(function* () { @@ -57,7 +62,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { yield* transport.notifications.sendSessionCancel({ sessionId: "session-1" }); const outbound = yield* Queue.take(output); - assert.deepEqual(JSON.parse(outbound), { + assert.deepEqual(decodeJson(outbound), { jsonrpc: "2.0", method: "session/cancel", params: { @@ -68,7 +73,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { yield* Queue.offer( input, encoder.encode( - `${JSON.stringify({ + `${encodeJson({ jsonrpc: "2.0", method: "session/update", params: { @@ -91,7 +96,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { yield* Queue.offer( input, encoder.encode( - `${JSON.stringify({ + `${encodeJson({ jsonrpc: "2.0", method: "session/elicitation/complete", params: { @@ -119,7 +124,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { .sendRequest("x/test", { hello: "world" }) .pipe(Effect.forkScoped); const outbound = yield* Queue.take(output); - assert.deepEqual(JSON.parse(outbound), { + assert.deepEqual(decodeJson(outbound), { jsonrpc: "2.0", id: 1, method: "x/test", @@ -132,7 +137,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { yield* Queue.offer( input, encoder.encode( - `${JSON.stringify({ + `${encodeJson({ jsonrpc: "2.0", id: 1, result: { diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 24c62863cc9..995e9ac28f6 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -29,11 +29,6 @@ export type AcpIncomingNotification = readonly method: typeof CLIENT_METHODS.session_update; readonly params: typeof AcpSchema.SessionNotification.Type; } - | { - readonly _tag: "SessionCancel"; - readonly method: "session/cancel"; - readonly params: typeof AcpSchema.CancelNotification.Type; - } | { readonly _tag: "ElicitationComplete"; readonly method: typeof CLIENT_METHODS.session_elicitation_complete; @@ -83,7 +78,6 @@ export interface AcpPatchedProtocol { } const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification); -const decodeSessionCancel = Schema.decodeUnknownEffect(AcpSchema.CancelNotification); const decodeElicitationComplete = Schema.decodeUnknownEffect( AcpSchema.ElicitationCompleteNotification, ); @@ -243,26 +237,6 @@ export const makeAcpPatchedProtocol = ( Effect.flatMap(dispatchNotification), ); } - if (message.tag === "session/cancel") { - return decodeSessionCancel(message.payload).pipe( - Effect.map( - (params) => - ({ - _tag: "SessionCancel", - method: "session/cancel", - params, - }) satisfies AcpIncomingNotification, - ), - Effect.mapError( - (cause) => - new AcpError.AcpProtocolParseError({ - detail: "Invalid session/cancel notification payload", - cause, - }), - ), - Effect.flatMap(dispatchNotification), - ); - } if (message.tag === CLIENT_METHODS.session_elicitation_complete) { return decodeElicitationComplete(message.payload).pipe( Effect.map( From 058822bbf8fbdf4d41b239c5f144bcc24507cbb9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 16:57:54 -0700 Subject: [PATCH 30/82] Extract ACP runtime event helpers and preserve model slugs - move ACP runtime event mapping into shared helpers - keep unrecognized Cursor ACP model slugs unchanged --- .../src/provider/Layers/CursorAdapter.ts | 218 ++---------------- .../provider/Layers/CursorProvider.test.ts | 8 +- .../provider/acp/AcpCoreRuntimeEvents.test.ts | 134 +++++++++++ .../src/provider/acp/AcpCoreRuntimeEvents.ts | 216 +++++++++++++++++ 4 files changed, 378 insertions(+), 198 deletions(-) create mode 100644 apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts create mode 100644 apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 0d803331814..a51d569ea57 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -8,8 +8,6 @@ import * as nodePath from "node:path"; import { ApprovalRequestId, EventId, - RuntimeItemId, - type ToolLifecycleItemType, type ProviderApprovalDecision, type ProviderInteractionMode, type ProviderRuntimeEvent, @@ -46,10 +44,16 @@ import { } from "../Errors.ts"; import { makeAcpSessionRuntime, type AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; import { type AcpSessionMode, type AcpSessionModeState, - type AcpToolCallState, parsePermissionRequest, } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; @@ -70,7 +74,6 @@ const CURSOR_RESUME_VERSION = 1 as const; const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; const ACP_APPROVAL_MODE_ALIASES = ["ask"]; -type AcpRawSource = "acp.jsonrpc" | "acp.cursor.extension"; export interface CursorAdapterLiveOptions { readonly nativeEventLogPath?: string; @@ -160,55 +163,6 @@ function isPlanMode(mode: AcpSessionMode): boolean { return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; } -function canonicalRequestTypeFromAcpKind( - kind: string | "unknown", -): "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" { - switch (kind) { - case "execute": - return "exec_command_approval"; - case "read": - return "file_read_approval"; - case "edit": - case "delete": - case "move": - return "file_change_approval"; - default: - return "unknown"; - } -} - -function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { - switch (kind) { - case "execute": - return "command_execution"; - case "edit": - case "delete": - case "move": - return "file_change"; - case "search": - case "fetch": - return "web_search"; - default: - return "dynamic_tool_call"; - } -} - -function runtimeItemStatusFromAcpToolStatus( - status: AcpToolCallState["status"], -): "inProgress" | "completed" | "failed" | undefined { - switch (status) { - case "pending": - case "inProgress": - return "inProgress"; - case "completed": - return "completed"; - case "failed": - return "failed"; - default: - return undefined; - } -} - function resolveRequestedModeId(input: { readonly interactionMode: ProviderInteractionMode | undefined; readonly runtimeMode: RuntimeMode; @@ -264,140 +218,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const offerRuntimeEvent = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); - const makeRequestOpenedEvent = (input: { - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly requestId: RuntimeRequestId; - readonly kind: string | "unknown"; - readonly detail: string; - readonly args: unknown; - readonly source: AcpRawSource; - readonly method: string; - readonly rawPayload: unknown; - }): Effect.Effect => - Effect.map(makeEventStamp(), (stamp) => ({ - type: "request.opened", - ...stamp, - provider: PROVIDER, - threadId: input.threadId, - turnId: input.turnId, - requestId: input.requestId, - payload: { - requestType: canonicalRequestTypeFromAcpKind(input.kind), - detail: input.detail, - args: input.args, - }, - raw: { - source: input.source, - method: input.method, - payload: input.rawPayload, - }, - })); - - const makeRequestResolvedEvent = (input: { - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly requestId: RuntimeRequestId; - readonly kind: string | "unknown"; - readonly decision: ProviderApprovalDecision; - }): Effect.Effect => - Effect.map(makeEventStamp(), (stamp) => ({ - type: "request.resolved", - ...stamp, - provider: PROVIDER, - threadId: input.threadId, - turnId: input.turnId, - requestId: input.requestId, - payload: { - requestType: canonicalRequestTypeFromAcpKind(input.kind), - decision: input.decision, - }, - })); - - const makePlanUpdatedEvent = (input: { - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly payload: { - readonly explanation?: string | null; - readonly plan: ReadonlyArray<{ - readonly step: string; - readonly status: "pending" | "inProgress" | "completed"; - }>; - }; - readonly source: AcpRawSource; - readonly method: string; - readonly rawPayload: unknown; - }): Effect.Effect => - Effect.map(makeEventStamp(), (stamp) => ({ - type: "turn.plan.updated", - ...stamp, - provider: PROVIDER, - threadId: input.threadId, - turnId: input.turnId, - payload: input.payload, - raw: { - source: input.source, - method: input.method, - payload: input.rawPayload, - }, - })); - - const makeToolCallRuntimeEvent = (input: { - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly toolCall: AcpToolCallState; - readonly rawPayload: unknown; - }): Effect.Effect => - Effect.map(makeEventStamp(), (stamp) => { - const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status); - return { - type: - input.toolCall.status === "completed" || input.toolCall.status === "failed" - ? "item.completed" - : "item.updated", - ...stamp, - provider: PROVIDER, - threadId: input.threadId, - turnId: input.turnId, - itemId: RuntimeItemId.makeUnsafe(input.toolCall.toolCallId), - payload: { - itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind), - ...(runtimeStatus ? { status: runtimeStatus } : {}), - ...(input.toolCall.title ? { title: input.toolCall.title } : {}), - ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), - ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), - }, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: input.rawPayload, - }, - } satisfies ProviderRuntimeEvent; - }); - - const makeContentDeltaEvent = (input: { - readonly threadId: ThreadId; - readonly turnId: TurnId | undefined; - readonly text: string; - readonly rawPayload: unknown; - }): Effect.Effect => - Effect.map(makeEventStamp(), (stamp) => ({ - type: "content.delta", - ...stamp, - provider: PROVIDER, - threadId: input.threadId, - turnId: input.turnId, - payload: { - streamKind: "assistant_text", - delta: input.text, - }, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: input.rawPayload, - }, - })); - const logNative = ( threadId: ThreadId, method: string, @@ -444,7 +264,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { } ctx.lastPlanFingerprint = fingerprint; yield* offerRuntimeEvent( - yield* makePlanUpdatedEvent({ + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, payload, @@ -654,11 +476,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { kind: permissionRequest.kind, }); yield* offerRuntimeEvent( - yield* makeRequestOpenedEvent({ + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, - kind: permissionRequest.kind, + permissionRequest, detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000), args: params, source: "acp.jsonrpc", @@ -669,11 +493,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const resolved = yield* Deferred.await(decision); pendingApprovals.delete(requestId); yield* offerRuntimeEvent( - yield* makeRequestResolvedEvent({ + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, - kind: permissionRequest.kind, + permissionRequest, decision: resolved, }), ); @@ -749,7 +575,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { case "ToolCallUpdated": yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); yield* offerRuntimeEvent( - yield* makeToolCallRuntimeEvent({ + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, toolCall: event.toolCall, @@ -760,7 +588,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { case "ContentDelta": yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); yield* offerRuntimeEvent( - yield* makeContentDeltaEvent({ + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, text: event.text, diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 01bed42f7b5..dc397c443a3 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -22,10 +22,10 @@ describe("resolveCursorAcpModelId", () => { ); }); - it("maps legacy cursor aliases onto the canonical base slug", () => { - expect(resolveCursorAcpModelId("gpt-5.4-1m", undefined)).toBe("gpt-5.4"); - expect(resolveCursorAcpModelId("auto", undefined)).toBe("auto[]"); - expect(resolveCursorAcpModelId("claude-4.6-opus", undefined)).toBe("claude-opus-4-6"); + it("preserves unrecognized ACP model slugs instead of forcing bracket notation", () => { + expect(resolveCursorAcpModelId("gpt-5.4-1m", undefined)).toBe("gpt-5.4-1m"); + expect(resolveCursorAcpModelId("auto", undefined)).toBe("auto"); + expect(resolveCursorAcpModelId("claude-4.6-opus", undefined)).toBe("claude-4.6-opus"); }); it("passes custom models through unchanged", () => { diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts new file mode 100644 index 00000000000..f40baf4143a --- /dev/null +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -0,0 +1,134 @@ +import { RuntimeRequestId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "./AcpCoreRuntimeEvents.ts"; + +describe("AcpCoreRuntimeEvents", () => { + it("maps ACP permission requests to canonical runtime events", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + const turnId = TurnId.makeUnsafe("turn-1"); + const permissionRequest = { + kind: "execute" as const, + detail: "cat package.json", + toolCall: { + toolCallId: "tool-1", + kind: "execute", + status: "pending" as const, + command: "cat package.json", + detail: "cat package.json", + data: { toolCallId: "tool-1", kind: "execute" }, + }, + }; + + expect( + makeAcpRequestOpenedEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId, + requestId: RuntimeRequestId.makeUnsafe("request-1"), + permissionRequest, + detail: "cat package.json", + args: { command: ["cat", "package.json"] }, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "request.opened", + payload: { + requestType: "exec_command_approval", + detail: "cat package.json", + }, + }); + + expect( + makeAcpRequestResolvedEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId, + requestId: RuntimeRequestId.makeUnsafe("request-1"), + permissionRequest, + decision: "accept", + }), + ).toMatchObject({ + type: "request.resolved", + payload: { + requestType: "exec_command_approval", + decision: "accept", + }, + }); + }); + + it("maps ACP core plan, tool-call, and content updates", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + const turnId = TurnId.makeUnsafe("turn-1"); + + expect( + makeAcpPlanUpdatedEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId, + payload: { + plan: [{ step: "Inspect state", status: "inProgress" }], + }, + source: "acp.cursor.extension", + method: "cursor/update_todos", + rawPayload: { todos: [] }, + }), + ).toMatchObject({ + type: "turn.plan.updated", + raw: { + method: "cursor/update_todos", + }, + }); + + expect( + makeAcpToolCallEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId, + toolCall: { + toolCallId: "tool-1", + kind: "execute", + status: "completed", + title: "Terminal", + detail: "bun run test", + data: { command: "bun run test" }, + }, + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "item.completed", + payload: { + itemType: "command_execution", + status: "completed", + }, + }); + + expect( + makeAcpContentDeltaEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId, + text: "hello", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "content.delta", + payload: { + delta: "hello", + }, + }); + }); +}); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts new file mode 100644 index 00000000000..0164f7aea10 --- /dev/null +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -0,0 +1,216 @@ +import { + RuntimeItemId, + type CanonicalRequestType, + type EventId, + type ProviderApprovalDecision, + type ProviderKind, + type ProviderRuntimeEvent, + type RuntimeRequestId, + type ThreadId, + type ToolLifecycleItemType, + type TurnId, +} from "@t3tools/contracts"; + +import type { AcpPermissionRequest, AcpPlanUpdate, AcpToolCallState } from "./AcpRuntimeModel.ts"; + +export type AcpAdapterRawSource = "acp.jsonrpc" | `acp.${string}.extension`; + +export interface AcpEventStamp { + readonly eventId: EventId; + readonly createdAt: string; +} + +type AcpCanonicalRequestType = Extract< + CanonicalRequestType, + "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" +>; + +function canonicalRequestTypeFromAcpKind( + kind: string | "unknown", +): AcpCanonicalRequestType { + switch (kind) { + case "execute": + return "exec_command_approval"; + case "read": + return "file_read_approval"; + case "edit": + case "delete": + case "move": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { + switch (kind) { + case "execute": + return "command_execution"; + case "edit": + case "delete": + case "move": + return "file_change"; + case "search": + case "fetch": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + +function runtimeItemStatusFromAcpToolStatus( + status: AcpToolCallState["status"], +): "inProgress" | "completed" | "failed" | undefined { + switch (status) { + case "pending": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return undefined; + } +} + +export function makeAcpRequestOpenedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly requestId: RuntimeRequestId; + readonly permissionRequest: AcpPermissionRequest; + readonly detail: string; + readonly args: unknown; + readonly source: AcpAdapterRawSource; + readonly method: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "request.opened", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + requestId: input.requestId, + payload: { + requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), + detail: input.detail, + args: input.args, + }, + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + }; +} + +export function makeAcpRequestResolvedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly requestId: RuntimeRequestId; + readonly permissionRequest: AcpPermissionRequest; + readonly decision: ProviderApprovalDecision; +}): ProviderRuntimeEvent { + return { + type: "request.resolved", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + requestId: input.requestId, + payload: { + requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind), + decision: input.decision, + }, + }; +} + +export function makeAcpPlanUpdatedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly payload: AcpPlanUpdate; + readonly source: AcpAdapterRawSource; + readonly method: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "turn.plan.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + payload: input.payload, + raw: { + source: input.source, + method: input.method, + payload: input.rawPayload, + }, + }; +} + +export function makeAcpToolCallEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly toolCall: AcpToolCallState; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status); + return { + type: + input.toolCall.status === "completed" || input.toolCall.status === "failed" + ? "item.completed" + : "item.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + itemId: RuntimeItemId.makeUnsafe(input.toolCall.toolCallId), + payload: { + itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind), + ...(runtimeStatus ? { status: runtimeStatus } : {}), + ...(input.toolCall.title ? { title: input.toolCall.title } : {}), + ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}), + ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}), + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + }; +} + +export function makeAcpContentDeltaEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly text: string; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "content.delta", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + payload: { + streamKind: "assistant_text", + delta: input.text, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + }; +} From 146e102dedac9e3b5382ac8f72c01e00f5f71659 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 16:59:13 -0700 Subject: [PATCH 31/82] Constrain ACP runtime event source types - derive ACP adapter raw sources from shared runtime event sources - keep canonical request mapping typed consistently --- apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index 0164f7aea10..2d65dc74f01 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -1,4 +1,5 @@ import { + type RuntimeEventRawSource, RuntimeItemId, type CanonicalRequestType, type EventId, @@ -13,9 +14,12 @@ import { import type { AcpPermissionRequest, AcpPlanUpdate, AcpToolCallState } from "./AcpRuntimeModel.ts"; -export type AcpAdapterRawSource = "acp.jsonrpc" | `acp.${string}.extension`; +type AcpAdapterRawSource = Extract< + RuntimeEventRawSource, + "acp.jsonrpc" | `acp.${string}.extension` +>; -export interface AcpEventStamp { +interface AcpEventStamp { readonly eventId: EventId; readonly createdAt: string; } @@ -25,9 +29,7 @@ type AcpCanonicalRequestType = Extract< "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" >; -function canonicalRequestTypeFromAcpKind( - kind: string | "unknown", -): AcpCanonicalRequestType { +function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType { switch (kind) { case "execute": return "exec_command_approval"; From f2614d01d496da17f8235707f73cfd564d2701bc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 17:16:17 -0700 Subject: [PATCH 32/82] Settle pending user input on session stop - Cancel pending approvals when stopping a Cursor session - Resolve pending user-input waits so sendTurn can exit cleanly - Add regression coverage for both stop paths --- apps/server/scripts/acp-mock-agent.ts | 40 ++++++++ .../src/provider/Layers/CursorAdapter.test.ts | 91 ++++++++++++++++++- .../src/provider/Layers/CursorAdapter.ts | 15 +++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 3b6a596399b..52f55766519 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -11,6 +11,7 @@ import { AGENT_METHODS, CLIENT_METHODS } from "effect-acp/schema"; const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const sessionId = "mock-session-1"; @@ -54,6 +55,7 @@ const availableModes = [ }, ]; const pendingPermissionRequests = new Map(); +const pendingAskQuestionRequests = new Map(); const cancelledPromptRequestIds = new Set(); function send(obj: unknown) { @@ -144,6 +146,17 @@ rl.on("line", (line) => { return; } + if (method === undefined && id !== undefined && pendingAskQuestionRequests.has(id)) { + const pending = pendingAskQuestionRequests.get(id); + pendingAskQuestionRequests.delete(id); + send({ + jsonrpc: "2.0", + id: pending.promptRequestId, + result: { stopReason: "end_turn" }, + }); + return; + } + if (method === AGENT_METHODS.initialize && id !== undefined) { send({ jsonrpc: "2.0", @@ -287,6 +300,33 @@ rl.on("line", (line) => { }); return; } + if (emitAskQuestion) { + const askQuestionRequestId = nextRequestId++; + pendingAskQuestionRequests.set(askQuestionRequestId, { + promptRequestId: id, + sessionId: requestedSessionId, + }); + send({ + jsonrpc: "2.0", + id: askQuestionRequestId, + method: "cursor/ask_question", + params: { + toolCallId: "ask-question-tool-call-1", + title: "Question", + questions: [ + { + id: "scope", + prompt: "Which scope?", + options: [ + { id: "workspace", label: "Workspace" }, + { id: "session", label: "Session" }, + ], + }, + ], + }, + }); + return; + } sendSessionUpdate( { sessionUpdate: "plan", diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 0bcc62d415f..2ee9c7a8ea9 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -352,7 +352,11 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { Effect.provide( makeCursorAdapterLive().pipe( Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-adapter-test-", + }), + ), Layer.provideMerge(NodeServices.layer), ), ), @@ -448,6 +452,91 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + it.effect("stopping a session settles pending approval waits", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-stop-pending-approval"); + const approvalRequested = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId) || event.type !== "request.opened") { + return Effect.void; + } + return Deferred.succeed(approvalRequested, undefined).pipe(Effect.ignore); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "run a tool call and then stop", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(approvalRequested); + yield* adapter.stopSession(threadId); + yield* Fiber.await(sendTurnFiber); + + assert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("stopping a session settles pending user-input waits", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-stop-pending-user-input"); + const userInputRequested = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_ASK_QUESTION: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId) || event.type !== "user-input.requested") { + return Effect.void; + } + return Deferred.succeed(userInputRequested, undefined).pipe(Effect.ignore); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "ask me a question and then stop", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(userInputRequested); + yield* adapter.stopSession(threadId); + yield* Fiber.await(sendTurnFiber); + + assert.equal(yield* adapter.hasSession(threadId), false); + }), + ); it.effect("switches model in-session via session/set_config_option", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index a51d569ea57..54e16b01f83 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -115,6 +115,19 @@ function settlePendingApprovalsAsCancelled( ); } +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -293,6 +306,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { Effect.gen(function* () { if (ctx.stopped) return; ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); if (ctx.notificationFiber) { yield* Fiber.interrupt(ctx.notificationFiber); } From fe988ecdae471805aa456c97c4019df98d741cf3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 17:21:56 -0700 Subject: [PATCH 33/82] Log outgoing ACP notifications before sending - Emit decoded and raw protocol log events for notifications - Cover logOutgoing behavior with a protocol test --- packages/effect-acp/src/protocol.test.ts | 38 +++++++++++ packages/effect-acp/src/protocol.ts | 82 +++++++++++++----------- 2 files changed, 84 insertions(+), 36 deletions(-) diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 7c8e567275a..10c1a8bd837 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -112,6 +112,44 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("logs outgoing notifications when logOutgoing is enabled", () => + Effect.gen(function* () { + const { stdio } = yield* makeInMemoryStdio(); + const events: Array = []; + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + logOutgoing: true, + logger: (event) => + Effect.sync(() => { + events.push(event); + }), + }); + + yield* transport.notifications.sendSessionCancel({ sessionId: "session-1" }); + + assert.deepEqual(events, [ + { + direction: "outgoing", + stage: "decoded", + payload: { + jsonrpc: "2.0", + method: "session/cancel", + params: { + sessionId: "session-1", + }, + }, + }, + { + direction: "outgoing", + stage: "raw", + payload: + '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}\n', + }, + ]); + }), + ); + it.effect("supports generic extension requests over the patched transport", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 995e9ac28f6..72b45c4def0 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -445,43 +445,53 @@ export const makeAcpPatchedProtocol = ( supportsSpanPropagation: true, }); - const sendNotification = (method: string, payload: unknown) => - Queue.offer( - outgoing, - `${JSON.stringify({ - jsonrpc: "2.0", - method, - ...(payload !== undefined ? { params: payload } : {}), - })}\n`, - ).pipe(Effect.asVoid); - - const sendRequest = (method: string, payload: unknown) => - Effect.gen(function* () { - const requestId = yield* Ref.modify( - nextRequestId, - (current) => [current, current + 1n] as const, - ); - const deferred = yield* Deferred.make(); - yield* Ref.update(extPending, (pending) => - new Map(pending).set(String(requestId), deferred), - ); - yield* offerOutgoing({ - _tag: "Request", - id: String(requestId), - tag: method, - payload, - headers: [], - }).pipe( - Effect.catch((error) => - Ref.update(extPending, (pending) => { - const next = new Map(pending); - next.delete(String(requestId)); - return next; - }).pipe(Effect.andThen(Effect.fail(error))), - ), - ); - return yield* Deferred.await(deferred); + const sendNotification = Effect.fn("sendNotification")(function* ( + method: string, + payload: unknown, + ) { + const message = { + jsonrpc: "2.0" as const, + method, + ...(payload !== undefined ? { params: payload } : {}), + }; + yield* logProtocol({ + direction: "outgoing", + stage: "decoded", + payload: message, + }); + const encoded = `${JSON.stringify(message)}\n`; + yield* logProtocol({ + direction: "outgoing", + stage: "raw", + payload: encoded, }); + yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid); + }); + + const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { + const requestId = yield* Ref.modify( + nextRequestId, + (current) => [current, current + 1n] as const, + ); + const deferred = yield* Deferred.make(); + yield* Ref.update(extPending, (pending) => new Map(pending).set(String(requestId), deferred)); + yield* offerOutgoing({ + _tag: "Request", + id: String(requestId), + tag: method, + payload, + headers: [], + }).pipe( + Effect.catch((error) => + Ref.update(extPending, (pending) => { + const next = new Map(pending); + next.delete(String(requestId)); + return next; + }).pipe(Effect.andThen(Effect.fail(error))), + ), + ); + return yield* Deferred.await(deferred); + }); return { clientProtocol, From c4e9e4075bf9ad9d1ef4105b4b1b35cb27204c00 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 17:32:11 -0700 Subject: [PATCH 34/82] Remove child-process export from effect-acp package - Drop the `./child-process` subpath export - Stop building `src/child-process.ts` in package scripts --- packages/effect-acp/package.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index 117c358a2d6..7001478fe19 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -27,18 +27,14 @@ "types": "./src/terminal.ts", "import": "./src/terminal.ts" }, - "./child-process": { - "types": "./src/child-process.ts", - "import": "./src/child-process.ts" - }, "./errors": { "types": "./src/errors.ts", "import": "./src/errors.ts" } }, "scripts": { - "dev": "tsdown src/client.ts src/server.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/child-process.ts src/terminal.ts --format esm,cjs --dts --watch --clean", - "build": "tsdown src/client.ts src/server.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/child-process.ts src/terminal.ts --format esm,cjs --dts --clean", + "dev": "tsdown src/client.ts src/server.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --watch --clean", + "build": "tsdown src/client.ts src/server.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --clean", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", "test": "vitest run", From 65621de91d1ef49032fe1332740ea046886e84a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 17:36:57 -0700 Subject: [PATCH 35/82] Patch ACP protocol to use request envelopes - Encode outgoing messages through ACP request envelopes - Add coverage for notification encoding failures --- packages/effect-acp/src/protocol.test.ts | 37 +++++++++++++++++++++--- packages/effect-acp/src/protocol.ts | 22 ++++---------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 10c1a8bd837..2772263460e 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -1,3 +1,4 @@ +import * as AcpError from "./errors"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -64,6 +65,8 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { const outbound = yield* Queue.take(output); assert.deepEqual(decodeJson(outbound), { jsonrpc: "2.0", + id: "", + headers: [], method: "session/cancel", params: { sessionId: "session-1", @@ -133,23 +136,49 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { direction: "outgoing", stage: "decoded", payload: { - jsonrpc: "2.0", - method: "session/cancel", - params: { + _tag: "Request", + id: "", + tag: "session/cancel", + payload: { sessionId: "session-1", }, + headers: [], }, }, { direction: "outgoing", stage: "raw", payload: - '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}\n', + '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"},"id":"","headers":[]}\n', }, ]); }), ); + it.effect("fails notification encoding through the declared ACP error channel", () => + Effect.gen(function* () { + const { stdio } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const bigintError = yield* transport.notifications + .sendExtNotification("x/test", 1n) + .pipe(Effect.flip); + assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); + assert.equal(bigintError.detail, "Failed to encode ACP message"); + + const circular: Record = {}; + circular.self = circular; + const circularError = yield* transport.notifications + .sendExtNotification("x/test", circular) + .pipe(Effect.flip); + assert.instanceOf(circularError, AcpError.AcpProtocolParseError); + assert.equal(circularError.detail, "Failed to encode ACP message"); + }), + ); + it.effect("supports generic extension requests over the patched transport", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 72b45c4def0..35b297ef8f9 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -449,23 +449,13 @@ export const makeAcpPatchedProtocol = ( method: string, payload: unknown, ) { - const message = { - jsonrpc: "2.0" as const, - method, - ...(payload !== undefined ? { params: payload } : {}), - }; - yield* logProtocol({ - direction: "outgoing", - stage: "decoded", - payload: message, - }); - const encoded = `${JSON.stringify(message)}\n`; - yield* logProtocol({ - direction: "outgoing", - stage: "raw", - payload: encoded, + yield* offerOutgoing({ + _tag: "Request", + id: "", + tag: method, + payload, + headers: [], }); - yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid); }); const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { From cad3cbea14bc6c28e872d18359d67fb4a359b67f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 17:46:44 -0700 Subject: [PATCH 36/82] Propagate ACP child exits through protocol termination - Thread child-process exit codes into protocol shutdown - Distinguish stream end, decode failure, and process exit errors - Add protocol coverage for pending requests and late responses --- packages/effect-acp/src/client.ts | 9 + packages/effect-acp/src/errors.ts | 4 +- packages/effect-acp/src/protocol.test.ts | 191 +++++++++++++++++- packages/effect-acp/src/protocol.ts | 138 +++++++++---- .../effect-acp/test/fixtures/acp-mock-peer.ts | 9 + 5 files changed, 306 insertions(+), 45 deletions(-) diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 72025a5a815..cc6327ba8ef 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -284,6 +284,15 @@ export const fromChildProcess = Effect.fnUntraced(function* ( const handlers = options.handlers ?? {}; const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio: makeStdioFromChildProcess(handle), + processExit: handle.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (cause) => + new AcpError.AcpProcessExitedError({ + cause, + }), + ), + ), serverRequestMethods: new Set(AcpRpcs.ClientRpcs.requests.keys()), ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}), ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}), diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index 496fd01b253..9aa4dbfeb11 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -22,8 +22,8 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass(); + const input = yield* Queue.unbounded>(); const output = yield* Queue.unbounded(); return { @@ -41,6 +45,48 @@ function makeInMemoryStdio() { }); } +const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => + path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), +); + +const makeHandle = (env?: Record) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const command = ChildProcess.make("bun", ["run", yield* mockPeerPath], { + cwd: path.join(import.meta.dirname, ".."), + shell: process.platform === "win32", + ...(env ? { env: { ...process.env, ...env } } : {}), + }); + return yield* spawner.spawn(command); + }); + +function makeChildStdio(handle: ChildProcessSpawner.ChildProcessHandle) { + return Stdio.make({ + args: Effect.succeed([]), + stdin: handle.stdout, + stdout: () => + Sink.mapInput(handle.stdin, (chunk: string | Uint8Array) => + typeof chunk === "string" ? encoder.encode(chunk) : chunk, + ), + stderr: () => Sink.drain, + }); +} + +function makeProcessExit( + handle: ChildProcessSpawner.ChildProcessHandle, +): Effect.Effect { + return handle.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (cause) => + new AcpError.AcpProcessExitedError({ + cause, + }), + ), + ); +} + it.layer(NodeServices.layer)("effect-acp protocol", (it) => { it.effect( "emits exact JSON-RPC notifications and decodes inbound session/update and elicitation completion", @@ -218,4 +264,147 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.deepEqual(resolved, { ok: true }); }), ); + + it.effect("cleans up interrupted extension requests before a late response arrives", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + const lateResponse = yield* Deferred.make(); + + yield* transport.clientProtocol + .run((message) => Deferred.succeed(lateResponse, message).pipe(Effect.asVoid)) + .pipe(Effect.forkScoped); + + const response = yield* transport + .sendRequest("x/test", { hello: "world" }) + .pipe(Effect.forkScoped); + const outbound = yield* Queue.take(output); + assert.deepEqual(decodeJson(outbound), { + jsonrpc: "2.0", + id: 1, + method: "x/test", + params: { + hello: "world", + }, + headers: [], + }); + + yield* Fiber.interrupt(response); + yield* Queue.offer( + input, + encoder.encode( + `${encodeJson({ + jsonrpc: "2.0", + id: 1, + result: { + ok: true, + }, + })}\n`, + ), + ); + + const message = yield* Deferred.await(lateResponse); + assert.deepEqual(message, { + _tag: "Exit", + requestId: "1", + exit: { + _tag: "Success", + value: { + ok: true, + }, + }, + }); + }), + ); + + it.effect("propagates the real child exit code when the input stream ends", () => + Effect.gen(function* () { + const handle = yield* makeHandle({ ACP_MOCK_EXIT_IMMEDIATELY_CODE: "7" }); + const firstMessage = yield* Deferred.make(); + const processExit = yield* Deferred.make(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio: makeChildStdio(handle), + processExit: makeProcessExit(handle), + serverRequestMethods: new Set(), + onProcessExit: (error) => Deferred.succeed(processExit, error).pipe(Effect.asVoid), + }); + + yield* transport.clientProtocol + .run((message) => Deferred.succeed(firstMessage, message).pipe(Effect.asVoid)) + .pipe(Effect.forkScoped); + + const message = yield* Deferred.await(firstMessage); + const exitError = yield* Deferred.await(processExit); + assert.instanceOf(exitError, AcpError.AcpProcessExitedError); + assert.equal(exitError.code, 7); + assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); + const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { + readonly _tag: string; + readonly cause: unknown; + }; + assert.equal(defect._tag, "RpcClientDefect"); + assert.instanceOf(defect.cause, AcpError.AcpProcessExitedError); + assert.equal((defect.cause as AcpError.AcpProcessExitedError).code, 7); + }), + ); + + it.effect("does not emit a second process-exit error after a decode failure", () => + Effect.gen(function* () { + const handle = yield* makeHandle({ + ACP_MOCK_MALFORMED_OUTPUT: "1", + ACP_MOCK_MALFORMED_OUTPUT_EXIT_CODE: "23", + }); + const processExitCalls = yield* Ref.make(0); + const firstMessage = yield* Deferred.make(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio: makeChildStdio(handle), + processExit: makeProcessExit(handle), + serverRequestMethods: new Set(), + onProcessExit: () => Ref.update(processExitCalls, (count) => count + 1), + }); + + yield* transport.clientProtocol + .run((message) => Deferred.succeed(firstMessage, message).pipe(Effect.asVoid)) + .pipe(Effect.forkScoped); + + const message = yield* Deferred.await(firstMessage); + assert.equal(yield* Ref.get(processExitCalls), 0); + assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); + const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { + readonly _tag: string; + readonly cause: unknown; + }; + assert.equal(defect._tag, "RpcClientDefect"); + assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError); + }), + ); + + it.effect("fails pending extension requests with the propagated exit code", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + processExit: Effect.succeed(0), + serverRequestMethods: new Set(), + }); + + const response = yield* transport + .sendRequest("x/test", { hello: "world" }) + .pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.end(input); + + const error = yield* Fiber.join(response).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected request to fail after process exit"), + }), + ); + assert.instanceOf(error, AcpError.AcpProcessExitedError); + assert.equal(error.code, 0); + }), + ); }); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 35b297ef8f9..2eefc1807fd 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -42,6 +42,7 @@ export type AcpIncomingNotification = export interface AcpPatchedProtocolOptions { readonly stdio: Stdio.Stdio; + readonly processExit?: Effect.Effect; readonly serverRequestMethods: ReadonlySet; readonly logIncoming?: boolean; readonly logOutgoing?: boolean; @@ -94,6 +95,7 @@ export const makeAcpPatchedProtocol = ( const disconnects = yield* Queue.unbounded(); const outgoing = yield* Queue.unbounded>(); const nextRequestId = yield* Ref.make(1n); + const terminationHandled = yield* Ref.make(false); const extPending = yield* Ref.make( new Map>(), ); @@ -152,6 +154,16 @@ export const makeAcpPatchedProtocol = ( return [onFound(deferred), next] as const; }).pipe(Effect.flatten); + const removeExtPending = (requestId: string) => + Ref.update(extPending, (pending) => { + if (!pending.has(requestId)) { + return pending; + } + const next = new Map(pending); + next.delete(requestId); + return next; + }); + const completeExtPendingFailure = (requestId: string, error: AcpError.AcpError) => resolveExtPending(requestId, (deferred) => Deferred.fail(deferred, error)); @@ -178,6 +190,47 @@ export const makeAcpPatchedProtocol = ( Effect.asVoid, ); + const emitClientProtocolError = (error: AcpError.AcpError) => + Queue.offer(clientQueue, { + _tag: "ClientProtocolError", + error: new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + }).pipe(Effect.asVoid); + + const handleTermination = ( + classify: () => Effect.Effect< + | { + readonly error: AcpError.AcpError; + readonly processExitError?: AcpError.AcpProcessExitedError | undefined; + } + | undefined + >, + ) => + Ref.modify(terminationHandled, (handled) => { + if (handled) { + return [Effect.void, true] as const; + } + return [ + Effect.gen(function* () { + yield* Queue.offer(disconnects, 0); + const terminated = yield* classify(); + if (!terminated) { + return; + } + yield* failAllExtPending(terminated.error); + yield* emitClientProtocolError(terminated.error); + if (terminated.processExitError && options.onProcessExit) { + yield* options.onProcessExit(terminated.processExitError); + } + }), + true, + ] as const; + }).pipe(Effect.flatten); + const respondWithSuccess = (requestId: string, value: unknown) => offerOutgoing({ _tag: "Exit", @@ -376,43 +429,46 @@ export const makeAcpPatchedProtocol = ( ), ), ), - Effect.catch((error) => { - const normalized: AcpError.AcpError = Schema.is(AcpError.AcpError)(error) - ? error - : new AcpError.AcpTransportError({ - detail: error instanceof Error ? error.message : String(error), - cause: error, - }); - const rpcClientError = new RpcClientError.RpcClientError({ - reason: new RpcClientError.RpcClientDefect({ - message: normalized.message, - cause: normalized, - }), - }); - return Queue.offer(clientQueue, { - _tag: "ClientProtocolError", - error: rpcClientError, - }).pipe(Effect.asVoid); - }), - Effect.ensuring( - Effect.gen(function* () { - const error = new AcpError.AcpProcessExitedError({}); - yield* Queue.offer(disconnects, 0); - yield* failAllExtPending(error); - yield* Queue.offer(clientQueue, { - _tag: "ClientProtocolError", - error: new RpcClientError.RpcClientError({ - reason: new RpcClientError.RpcClientDefect({ - message: error.message, + Effect.matchEffect({ + onFailure: (error) => { + const normalized: AcpError.AcpError = Schema.is(AcpError.AcpError)(error) + ? error + : new AcpError.AcpTransportError({ + detail: error instanceof Error ? error.message : String(error), cause: error, - }), - }), - }); - if (options.onProcessExit) { - yield* options.onProcessExit(error); - } - }), - ), + }); + return handleTermination(() => Effect.succeed({ error: normalized })); + }, + onSuccess: () => + handleTermination(() => + options.processExit + ? options.processExit.pipe( + Effect.match({ + onFailure: (processExitError) => + ({ + error: processExitError, + processExitError, + }) as const, + onSuccess: (code) => { + const processExitError = + code === null + ? new AcpError.AcpProcessExitedError({}) + : new AcpError.AcpProcessExitedError({ code }); + return { + error: processExitError, + processExitError, + } as const; + }, + }), + ) + : Effect.succeed({ + error: new AcpError.AcpTransportError({ + detail: "ACP input stream ended", + cause: new Error("ACP input stream ended"), + }), + }), + ), + }), Effect.forkScoped, ); @@ -473,14 +529,12 @@ export const makeAcpPatchedProtocol = ( headers: [], }).pipe( Effect.catch((error) => - Ref.update(extPending, (pending) => { - const next = new Map(pending); - next.delete(String(requestId)); - return next; - }).pipe(Effect.andThen(Effect.fail(error))), + removeExtPending(String(requestId)).pipe(Effect.andThen(Effect.fail(error))), ), ); - return yield* Deferred.await(deferred); + return yield* Deferred.await(deferred).pipe( + Effect.onInterrupt(() => removeExtPending(String(requestId))), + ); }); return { diff --git a/packages/effect-acp/test/fixtures/acp-mock-peer.ts b/packages/effect-acp/test/fixtures/acp-mock-peer.ts index e55ed0f6ce9..12ea2a4b8f0 100644 --- a/packages/effect-acp/test/fixtures/acp-mock-peer.ts +++ b/packages/effect-acp/test/fixtures/acp-mock-peer.ts @@ -5,6 +5,15 @@ const rl = createInterface({ crlfDelay: Infinity, }); +if (process.env.ACP_MOCK_MALFORMED_OUTPUT === "1") { + process.stdout.write("{not-json}\n"); + process.exit(Number(process.env.ACP_MOCK_MALFORMED_OUTPUT_EXIT_CODE ?? "0")); +} + +if (process.env.ACP_MOCK_EXIT_IMMEDIATELY_CODE !== undefined) { + process.exit(Number(process.env.ACP_MOCK_EXIT_IMMEDIATELY_CODE)); +} + let nextRequestId = 1000; const pending = new Map< number | string, From ff8577adce5d7b5f6c24f63060b12208a2e3c447 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 18:51:06 -0700 Subject: [PATCH 37/82] servicify --- .../src/provider/Layers/CursorAdapter.ts | 324 +++---- .../provider/acp/AcpJsonRpcConnection.test.ts | 168 ++-- .../src/provider/acp/AcpSessionRuntime.ts | 387 +++++--- .../provider/acp/CursorAcpCliProbe.test.ts | 97 +- packages/effect-acp/package.json | 8 +- packages/effect-acp/src/client.test.ts | 257 +++--- packages/effect-acp/src/client.ts | 679 +++++++++----- packages/effect-acp/src/protocol.test.ts | 20 +- packages/effect-acp/src/protocol.ts | 867 +++++++++--------- packages/effect-acp/src/server.ts | 135 --- .../examples/cursor-acp-client.example.ts | 108 +-- 11 files changed, 1677 insertions(+), 1373 deletions(-) delete mode 100644 packages/effect-acp/src/server.ts diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 54e16b01f83..0d39fed5825 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -27,10 +27,10 @@ import { Layer, Queue, Random, + Scope, Stream, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { defineExtNotification, defineExtRequest } from "effect-acp/client"; import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -42,7 +42,7 @@ import { ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import { makeAcpSessionRuntime, type AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; +import { AcpSessionRuntime, type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; import { makeAcpContentDeltaEvent, @@ -92,7 +92,7 @@ interface PendingUserInput { interface CursorSessionContext { readonly threadId: ThreadId; session: ProviderSession; - readonly acp: AcpSessionRuntime; + readonly acp: AcpSessionRuntimeShape; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -379,158 +379,22 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { threadId: input.threadId, }); - const acp = yield* makeAcpSessionRuntime({ - spawn: spawnOptions, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - authMethodId: "cursor_login", - ...acpNativeLoggers, - handlers: { - extRequests: { - "cursor/ask_question": defineExtRequest(CursorAskQuestionRequest, (params) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", - ); - const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); - const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); - const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - payload: { questions: extractAskQuestions(params) }, - raw: { - source: "acp.cursor.extension", - method: "cursor/ask_question", - payload: params, - }, - }); - const resolved = yield* Deferred.await(answers); - pendingUserInputs.delete(requestId); - yield* offerRuntimeEvent({ - type: "user-input.resolved", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - payload: { answers: resolved }, - }); - return { answers: resolved }; - }), - ), - "cursor/create_plan": defineExtRequest(CursorCreatePlanRequest, (params) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/create_plan", - params, - "acp.cursor.extension", - ); - yield* offerRuntimeEvent({ - type: "turn.proposed.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - payload: { planMarkdown: extractPlanMarkdown(params) }, - raw: { - source: "acp.cursor.extension", - method: "cursor/create_plan", - payload: params, - }, - }); - return { accepted: true } as const; - }), - ), - }, - extNotifications: { - "cursor/update_todos": defineExtNotification(CursorUpdateTodosRequest, (params) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/update_todos", - params, - "acp.cursor.extension", - ); - if (ctx) { - yield* emitPlanUpdate( - ctx, - extractTodosAsPlan(params), - params, - "acp.cursor.extension", - "cursor/update_todos", - ); - } - }), - ), - }, - requestPermission: (params) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "session/request_permission", - params, - "acp.jsonrpc", - ); - const permissionRequest = parsePermissionRequest(params); - const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); - const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); - const decision = yield* Deferred.make(); - pendingApprovals.set(requestId, { - decision, - kind: permissionRequest.kind, - }); - yield* offerRuntimeEvent( - makeAcpRequestOpenedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000), - args: params, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: params, - }), - ); - const resolved = yield* Deferred.await(decision); - pendingApprovals.delete(requestId); - yield* offerRuntimeEvent( - makeAcpRequestResolvedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - decision: resolved, - }), - ); - return { - outcome: - resolved === "cancel" - ? ({ outcome: "cancelled" } as const) - : { - outcome: "selected" as const, - optionId: acpPermissionOutcome(resolved), - }, - }; - }), - }, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + const acpContextScope = yield* Scope.make("sequential"); + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + spawn: spawnOptions, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + authMethodId: "cursor_login", + ...acpNativeLoggers, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + ), + ).pipe( + Effect.provideService(Scope.Scope, acpContextScope), Effect.mapError( (cause) => new ProviderAdapterProcessError({ @@ -541,6 +405,150 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }), ), ); + const acp = yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + const started = yield* Effect.gen(function* () { + yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/ask_question", + params, + "acp.cursor.extension", + ); + const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); + const answers = yield* Deferred.make(); + pendingUserInputs.set(requestId, { answers }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { questions: extractAskQuestions(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/ask_question", + payload: params, + }, + }); + const resolved = yield* Deferred.await(answers); + pendingUserInputs.delete(requestId); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: resolved }, + }); + return { answers: resolved }; + }), + ); + yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/create_plan", + params, + "acp.cursor.extension", + ); + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + payload: { planMarkdown: extractPlanMarkdown(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/create_plan", + payload: params, + }, + }); + return { accepted: true } as const; + }), + ); + yield* acp.handleExtNotification( + "cursor/update_todos", + CursorUpdateTodosRequest, + (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/update_todos", + params, + "acp.cursor.extension", + ); + if (ctx) { + yield* emitPlanUpdate( + ctx, + extractTodosAsPlan(params), + params, + "acp.cursor.extension", + "cursor/update_todos", + ); + } + }), + ); + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params, "acp.jsonrpc"); + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000), + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); const now = yield* nowIso; const session: ProviderSession = { @@ -552,7 +560,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { threadId: input.threadId, resumeCursor: { schemaVersion: CURSOR_RESUME_VERSION, - sessionId: acp.sessionId, + sessionId: started.sessionId, }, createdAt: now, updatedAt: now, @@ -626,7 +634,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - payload: { resume: acp.initializeResult }, + payload: { resume: started.initializeResult }, }); yield* offerRuntimeEvent({ type: "session.state.changed", @@ -640,7 +648,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - payload: { providerThreadId: acp.sessionId }, + payload: { providerThreadId: started.sessionId }, }); return session; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 50b799242e1..1c66d399ed0 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -8,7 +8,7 @@ import { it } from "@effect/vitest"; import { Effect, Stream } from "effect"; import { describe, expect } from "vitest"; -import { makeAcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts"; +import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts"; import type * as EffectAcpProtocol from "effect-acp/protocol"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -18,18 +18,11 @@ const bunExe = "bun"; describe("AcpSessionRuntime", () => { it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => Effect.gen(function* () { - const runtime = yield* makeAcpSessionRuntime({ - spawn: { - command: bunExe, - args: [mockAgentPath], - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - }); + const runtime = yield* AcpSessionRuntime; + const started = yield* runtime.start(); - expect(runtime.initializeResult).toMatchObject({ protocolVersion: 1 }); - expect(runtime.sessionId).toBe("mock-session-1"); + expect(started.initializeResult).toMatchObject({ protocolVersion: 1 }); + expect(started.sessionId).toBe("mock-session-1"); const promptResult = yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }], @@ -46,25 +39,28 @@ describe("AcpSessionRuntime", () => { } yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), ); - it.effect("logs ACP requests from the shared runtime", () => - Effect.gen(function* () { - const requestEvents: Array = []; - const runtime = yield* makeAcpSessionRuntime({ - authMethodId: "test", - spawn: { - command: bunExe, - args: [mockAgentPath], - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }); + it.effect("logs ACP requests from the shared runtime", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); yield* runtime.setModel("composer-2"); yield* runtime.prompt({ @@ -93,29 +89,32 @@ describe("AcpSessionRuntime", () => { ).toBe(true); yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => - Effect.gen(function* () { - const protocolEvents: Array = []; - const runtime = yield* makeAcpSessionRuntime({ - authMethodId: "test", - spawn: { - command: bunExe, - args: [mockAgentPath], - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - protocolLogging: { - logIncoming: true, - logOutgoing: true, - logger: (event) => + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => Effect.sync(() => { - protocolEvents.push(event); + requestEvents.push(event); }), - }, - }); + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + + it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => { + const protocolEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }], @@ -135,25 +134,37 @@ describe("AcpSessionRuntime", () => { ).toBe(true); yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("rejects invalid config option values before sending session/set_config_option", () => - Effect.gen(function* () { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-")); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - const runtime = yield* makeAcpSessionRuntime({ - authMethodId: "test", - spawn: { - command: bunExe, - args: [mockAgentPath], - env: { - T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - }); + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + protocolLogging: { + logIncoming: true, + logOutgoing: true, + logger: (event) => + Effect.sync(() => { + protocolEvents.push(event); + }), + }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + + it.effect("rejects invalid config option values before sending session/set_config_option", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-")); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip); expect(error._tag).toBe("AcpRequestError"); @@ -181,6 +192,23 @@ describe("AcpSessionRuntime", () => { ).toBe(false); rmSync(tempDir, { recursive: true, force: true }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], + env: { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); }); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 31bf47f9591..db5f214223f 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,4 +1,15 @@ -import { Cause, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Layer, + Queue, + Ref, + Scope, + ServiceMap, + Stream, +} from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; @@ -33,7 +44,6 @@ export interface AcpSessionRuntimeOptions { readonly version: string; }; readonly authMethodId: string; - readonly handlers?: Omit; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -50,7 +60,7 @@ export interface AcpSessionRequestLogEvent { readonly cause?: Cause.Cause; } -export interface AcpSessionRuntime { +export interface AcpSessionRuntimeStartResult { readonly sessionId: string; readonly initializeResult: EffectAcpSchema.InitializeResponse; readonly sessionSetupResult: @@ -58,6 +68,25 @@ export interface AcpSessionRuntime { | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse; readonly modelConfigId: string | undefined; +} + +export interface AcpSessionRuntimeShape { + readonly handleRequestPermission: EffectAcpClient.AcpConnectionShape["handleRequestPermission"]; + readonly handleElicitation: EffectAcpClient.AcpConnectionShape["handleElicitation"]; + readonly handleReadTextFile: EffectAcpClient.AcpConnectionShape["handleReadTextFile"]; + readonly handleWriteTextFile: EffectAcpClient.AcpConnectionShape["handleWriteTextFile"]; + readonly handleCreateTerminal: EffectAcpClient.AcpConnectionShape["handleCreateTerminal"]; + readonly handleTerminalOutput: EffectAcpClient.AcpConnectionShape["handleTerminalOutput"]; + readonly handleTerminalWaitForExit: EffectAcpClient.AcpConnectionShape["handleTerminalWaitForExit"]; + readonly handleTerminalKill: EffectAcpClient.AcpConnectionShape["handleTerminalKill"]; + readonly handleTerminalRelease: EffectAcpClient.AcpConnectionShape["handleTerminalRelease"]; + readonly handleSessionUpdate: EffectAcpClient.AcpConnectionShape["handleSessionUpdate"]; + readonly handleElicitationComplete: EffectAcpClient.AcpConnectionShape["handleElicitationComplete"]; + readonly handleUnknownExtRequest: EffectAcpClient.AcpConnectionShape["handleUnknownExtRequest"]; + readonly handleUnknownExtNotification: EffectAcpClient.AcpConnectionShape["handleUnknownExtNotification"]; + readonly handleExtRequest: EffectAcpClient.AcpConnectionShape["handleExtRequest"]; + readonly handleExtNotification: EffectAcpClient.AcpConnectionShape["handleExtNotification"]; + readonly start: () => Effect.Effect; readonly events: Stream.Stream; readonly getModeState: Effect.Effect; readonly prompt: ( @@ -83,10 +112,35 @@ export interface AcpSessionRuntime { readonly close: Effect.Effect; } -export const makeAcpSessionRuntime = ( +interface AcpStartedState extends AcpSessionRuntimeStartResult {} + +type AcpStartState = + | { readonly _tag: "NotStarted" } + | { + readonly _tag: "Starting"; + readonly deferred: Deferred.Deferred; + } + | { readonly _tag: "Started"; readonly result: AcpStartedState }; + +export class AcpSessionRuntime extends ServiceMap.Service< + AcpSessionRuntime, + AcpSessionRuntimeShape +>()("t3/provider/acp/AcpSessionRuntime") { + static layer( + options: AcpSessionRuntimeOptions, + ): Layer.Layer< + AcpSessionRuntime, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner + > { + return Layer.effect(AcpSessionRuntime, makeAcpSessionRuntime(options)); + } +} + +const makeAcpSessionRuntime = ( options: AcpSessionRuntimeOptions, ): Effect.Effect< - AcpSessionRuntime, + AcpSessionRuntimeShape, EffectAcpErrors.AcpError, ChildProcessSpawner.ChildProcessSpawner > => @@ -97,6 +151,7 @@ export const makeAcpSessionRuntime = ( const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); + const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const logRequest = (event: AcpSessionRequestLogEvent) => options.requestLogger ? options.requestLogger(event) : Effect.void; @@ -148,101 +203,42 @@ export const makeAcpSessionRuntime = ( ), ); - const client = yield* EffectAcpClient.fromChildProcess(child, { - ...(options.protocolLogging?.logIncoming !== undefined - ? { logIncoming: options.protocolLogging.logIncoming } - : {}), - ...(options.protocolLogging?.logOutgoing !== undefined - ? { logOutgoing: options.protocolLogging.logOutgoing } - : {}), - ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), - handlers: { - ...options.handlers, - sessionUpdate: (notification) => - handleSessionUpdate({ - queue: eventQueue, - modeStateRef, - toolCallsRef, - params: notification, - }), - }, - }).pipe(Effect.provideService(Scope.Scope, runtimeScope)); - - const initializePayload = { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, - clientInfo: options.clientInfo, - } satisfies EffectAcpSchema.InitializeRequest; - - const initializeResult = yield* runLoggedRequest( - "initialize", - initializePayload, - client.initialize(initializePayload), - ); + const acpContext = yield* Layer.build( + EffectAcpClient.layerFromChildProcessHandle(child, { + ...(options.protocolLogging?.logIncoming !== undefined + ? { logIncoming: options.protocolLogging.logIncoming } + : {}), + ...(options.protocolLogging?.logOutgoing !== undefined + ? { logOutgoing: options.protocolLogging.logOutgoing } + : {}), + ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), + }), + ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + const acp = yield* Effect.service(EffectAcpClient.AcpConnection).pipe( + Effect.provide(acpContext), + ); - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - client.authenticate(authenticatePayload), + yield* acp.handleSessionUpdate((notification) => + handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + toolCallsRef, + params: notification, + }), ); + const close = Scope.close(runtimeScope, Exit.void).pipe(Effect.asVoid); - let sessionId: string; - let sessionSetupResult: - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse; - if (options.resumeSessionId) { - const loadPayload = { - sessionId: options.resumeSessionId, - cwd: options.cwd, - mcpServers: [], - } satisfies EffectAcpSchema.LoadSessionRequest; - const resumed = yield* runLoggedRequest( - "session/load", - loadPayload, - client.loadSession(loadPayload), - ).pipe(Effect.exit); - if (Exit.isSuccess(resumed)) { - sessionId = options.resumeSessionId; - sessionSetupResult = resumed.value; - } else { - const createPayload = { - cwd: options.cwd, - mcpServers: [], - } satisfies EffectAcpSchema.NewSessionRequest; - const created = yield* runLoggedRequest( - "session/new", - createPayload, - client.createSession(createPayload), - ); - sessionId = created.sessionId; - sessionSetupResult = created; + const getStartedState = Effect.gen(function* () { + const state = yield* Ref.get(startStateRef); + if (state._tag === "Started") { + return state.result; } - } else { - const createPayload = { - cwd: options.cwd, - mcpServers: [], - } satisfies EffectAcpSchema.NewSessionRequest; - const created = yield* runLoggedRequest( - "session/new", - createPayload, - client.createSession(createPayload), - ); - sessionId = created.sessionId; - sessionSetupResult = created; - } - - yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); - yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); - - const close = Scope.close(runtimeScope, Exit.void).pipe(Effect.asVoid); + return yield* new EffectAcpErrors.AcpTransportError({ + detail: "ACP session runtime has not been started", + cause: new Error("ACP session runtime has not been started"), + }); + }); const validateConfigOptionValue = ( configId: string, @@ -306,64 +302,201 @@ export const makeAcpSessionRuntime = ( value: string | boolean, ): Effect.Effect => validateConfigOptionValue(configId, value).pipe( - Effect.flatMap(() => { + Effect.flatMap(() => getStartedState), + Effect.flatMap((started) => { const requestPayload = typeof value === "boolean" ? ({ - sessionId, + sessionId: started.sessionId, configId, type: "boolean", value, } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) : ({ - sessionId, + sessionId: started.sessionId, configId, value: String(value), } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); return runLoggedRequest( "session/set_config_option", requestPayload, - client.setSessionConfigOption(requestPayload), + acp.setSessionConfigOption(requestPayload), ).pipe(Effect.tap((response) => updateConfigOptions(response))); }), ); + const startOnce = Effect.gen(function* () { + const initializePayload = { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: options.clientInfo, + } satisfies EffectAcpSchema.InitializeRequest; + + const initializeResult = yield* runLoggedRequest( + "initialize", + initializePayload, + acp.initialize(initializePayload), + ); + + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; + + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.authenticate(authenticatePayload), + ); + + let sessionId: string; + let sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + if (options.resumeSessionId) { + const loadPayload = { + sessionId: options.resumeSessionId, + cwd: options.cwd, + mcpServers: [], + } satisfies EffectAcpSchema.LoadSessionRequest; + const resumed = yield* runLoggedRequest( + "session/load", + loadPayload, + acp.loadSession(loadPayload), + ).pipe(Effect.exit); + if (Exit.isSuccess(resumed)) { + sessionId = options.resumeSessionId; + sessionSetupResult = resumed.value; + } else { + const createPayload = { + cwd: options.cwd, + mcpServers: [], + } satisfies EffectAcpSchema.NewSessionRequest; + const created = yield* runLoggedRequest( + "session/new", + createPayload, + acp.createSession(createPayload), + ); + sessionId = created.sessionId; + sessionSetupResult = created; + } + } else { + const createPayload = { + cwd: options.cwd, + mcpServers: [], + } satisfies EffectAcpSchema.NewSessionRequest; + const created = yield* runLoggedRequest( + "session/new", + createPayload, + acp.createSession(createPayload), + ); + sessionId = created.sessionId; + sessionSetupResult = created; + } + + yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + + const nextState = { + sessionId, + initializeResult, + sessionSetupResult, + modelConfigId: extractModelConfigId(sessionSetupResult), + } satisfies AcpStartedState; + return nextState; + }); + + const start = Effect.gen(function* () { + const deferred = yield* Deferred.make< + AcpSessionRuntimeStartResult, + EffectAcpErrors.AcpError + >(); + const effect = yield* Ref.modify(startStateRef, (state) => { + switch (state._tag) { + case "Started": + return [Effect.succeed(state.result), state] as const; + case "Starting": + return [Deferred.await(state.deferred), state] as const; + case "NotStarted": + return [ + startOnce.pipe( + Effect.tap((result) => + Ref.set(startStateRef, { _tag: "Started", result }).pipe( + Effect.andThen(Deferred.succeed(deferred, result)), + ), + ), + Effect.onError((cause) => + Deferred.failCause(deferred, cause).pipe( + Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), + ), + ), + ), + { _tag: "Starting", deferred } satisfies AcpStartState, + ] as const; + } + }); + return yield* effect; + }); + return { - sessionId, - initializeResult, - sessionSetupResult, - modelConfigId: extractModelConfigId(sessionSetupResult), + handleRequestPermission: acp.handleRequestPermission, + handleElicitation: acp.handleElicitation, + handleReadTextFile: acp.handleReadTextFile, + handleWriteTextFile: acp.handleWriteTextFile, + handleCreateTerminal: acp.handleCreateTerminal, + handleTerminalOutput: acp.handleTerminalOutput, + handleTerminalWaitForExit: acp.handleTerminalWaitForExit, + handleTerminalKill: acp.handleTerminalKill, + handleTerminalRelease: acp.handleTerminalRelease, + handleSessionUpdate: acp.handleSessionUpdate, + handleElicitationComplete: acp.handleElicitationComplete, + handleUnknownExtRequest: acp.handleUnknownExtRequest, + handleUnknownExtNotification: acp.handleUnknownExtNotification, + handleExtRequest: acp.handleExtRequest, + handleExtNotification: acp.handleExtNotification, + start: () => start, events: Stream.fromQueue(eventQueue), getModeState: Ref.get(modeStateRef), - prompt: (payload) => { - const requestPayload = { - sessionId, - ...payload, - } satisfies EffectAcpSchema.PromptRequest; - return runLoggedRequest("session/prompt", requestPayload, client.prompt(requestPayload)); - }, - cancel: client.cancel({ sessionId }), - setMode: (modeId) => { - const requestPayload = { - sessionId, - modeId, - } satisfies EffectAcpSchema.SetSessionModeRequest; - return runLoggedRequest( - "session/set_mode", - requestPayload, - client.setSessionMode(requestPayload), - ); - }, + prompt: (payload) => + getStartedState.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + return runLoggedRequest("session/prompt", requestPayload, acp.prompt(requestPayload)); + }), + ), + cancel: getStartedState.pipe( + Effect.flatMap((started) => acp.cancel({ sessionId: started.sessionId })), + ), + setMode: (modeId) => + getStartedState.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId: started.sessionId, + modeId, + } satisfies EffectAcpSchema.SetSessionModeRequest; + return runLoggedRequest( + "session/set_mode", + requestPayload, + acp.setSessionMode(requestPayload), + ); + }), + ), setConfigOption, setModel: (model) => - setConfigOption(extractModelConfigId(sessionSetupResult) ?? "model", model).pipe( + getStartedState.pipe( + Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), Effect.asVoid, ), - request: (method, payload) => - runLoggedRequest(method, payload, client.extRequest(method, payload)), - notify: client.extNotification, + request: (method, payload) => runLoggedRequest(method, payload, acp.request(method, payload)), + notify: acp.notify, close, - } satisfies AcpSessionRuntime; + } satisfies AcpSessionRuntimeShape; }); function sessionConfigOptionsFromSetup( diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index d6b771823a1..51182de1206 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -8,42 +8,41 @@ import { Effect } from "effect"; import { describe, expect } from "vitest"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { makeAcpSessionRuntime } from "./AcpSessionRuntime.ts"; +import { AcpSessionRuntime } from "./AcpSessionRuntime.ts"; describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => { it.effect("initialize and authenticate against real agent acp", () => Effect.gen(function* () { - const runtime = yield* makeAcpSessionRuntime({ - spawn: { - command: "agent", - args: ["acp"], - cwd: process.cwd(), - }, - cwd: process.cwd(), - clientInfo: { name: "t3-probe", version: "0.0.0" }, - authMethodId: "cursor_login", - }); - expect(runtime.initializeResult).toBeDefined(); + const runtime = yield* AcpSessionRuntime; + const started = yield* runtime.start(); + expect(started.initializeResult).toBeDefined(); yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientInfo: { name: "t3-probe", version: "0.0.0" }, + authMethodId: "cursor_login", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), ); it.effect("session/new returns configOptions with a model selector", () => Effect.gen(function* () { - const runtime = yield* makeAcpSessionRuntime({ - authMethodId: "cursor_login", - spawn: { - command: "agent", - args: ["acp"], - cwd: process.cwd(), - }, - cwd: process.cwd(), - clientInfo: { name: "t3-probe", version: "0.0.0" }, - }); - const result = runtime.sessionSetupResult; + const runtime = yield* AcpSessionRuntime; + const started = yield* runtime.start(); + const result = started.sessionSetupResult; console.log("session/new result:", JSON.stringify(result, null, 2)); - expect(typeof runtime.sessionId).toBe("string"); + expect(typeof started.sessionId).toBe("string"); const configOptions = result.configOptions; console.log("session/new configOptions:", JSON.stringify(configOptions, null, 2)); @@ -55,22 +54,29 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", expect(typeof modelConfig?.id).toBe("string"); } yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "cursor_login", + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), ); it.effect("session/set_config_option switches the model in-session", () => Effect.gen(function* () { - const runtime = yield* makeAcpSessionRuntime({ - authMethodId: "cursor_login", - spawn: { - command: "agent", - args: ["acp"], - cwd: process.cwd(), - }, - cwd: process.cwd(), - clientInfo: { name: "t3-probe", version: "0.0.0" }, - }); - const newResult = runtime.sessionSetupResult; + const runtime = yield* AcpSessionRuntime; + const started = yield* runtime.start(); + const newResult = started.sessionSetupResult; const configOptions = newResult.configOptions; let modelConfigId = "model"; @@ -93,6 +99,21 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", } } yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "cursor_login", + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), ); }); diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index 7001478fe19..4c9fb019ec6 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -7,10 +7,6 @@ "types": "./src/client.ts", "import": "./src/client.ts" }, - "./server": { - "types": "./src/server.ts", - "import": "./src/server.ts" - }, "./schema": { "types": "./src/schema.ts", "import": "./src/schema.ts" @@ -33,8 +29,8 @@ } }, "scripts": { - "dev": "tsdown src/client.ts src/server.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --watch --clean", - "build": "tsdown src/client.ts src/server.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --clean", + "dev": "tsdown src/client.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --watch --clean", + "build": "tsdown src/client.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --clean", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 91734127dbf..9d372bcaa34 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -1,8 +1,11 @@ import * as Path from "effect/Path"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -35,89 +38,95 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { const typedRequests = yield* Ref.make>([]); const typedNotifications = yield* Ref.make>([]); const handle = yield* makeHandle(); - - const client = yield* AcpClient.fromChildProcess(handle, { - handlers: { - requestPermission: () => - Effect.succeed({ - outcome: { - outcome: "selected", - optionId: "allow", - }, - }), - elicitation: () => - Effect.succeed({ - action: { - action: "accept", - content: { - approved: true, - }, + const scope = yield* Scope.make(); + const acpLayer = AcpClient.layerFromChildProcessHandle(handle); + const context = yield* Layer.buildWithScope(acpLayer, scope); + + const ext = yield* Effect.gen(function* () { + const acp = yield* AcpClient.AcpConnection; + + yield* acp.handleRequestPermission(() => + Effect.succeed({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }), + ); + yield* acp.handleElicitation(() => + Effect.succeed({ + action: { + action: "accept", + content: { + approved: true, }, - }), - sessionUpdate: (notification) => - Ref.update(updates, (current) => [...current, notification]), - elicitationComplete: (notification) => - Ref.update(elicitationCompletions, (current) => [...current, notification]), - extRequests: { - "x/typed_request": AcpClient.defineExtRequest( - Schema.Struct({ message: Schema.String }), - (payload) => - Ref.update(typedRequests, (current) => [...current, payload]).pipe( - Effect.as({ - ok: true, - echoedMessage: payload.message, - }), - ), + }, + }), + ); + yield* acp.handleSessionUpdate((notification) => + Ref.update(updates, (current) => [...current, notification]), + ); + yield* acp.handleElicitationComplete((notification) => + Ref.update(elicitationCompletions, (current) => [...current, notification]), + ); + yield* acp.handleExtRequest( + "x/typed_request", + Schema.Struct({ message: Schema.String }), + (payload) => + Ref.update(typedRequests, (current) => [...current, payload]).pipe( + Effect.as({ + ok: true, + echoedMessage: payload.message, + }), ), + ); + yield* acp.handleExtNotification( + "x/typed_notification", + Schema.Struct({ count: Schema.Number }), + (payload) => Ref.update(typedNotifications, (current) => [...current, payload]), + ); + + const init = yield* acp.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, }, - extNotifications: { - "x/typed_notification": AcpClient.defineExtNotification( - Schema.Struct({ count: Schema.Number }), - (payload) => Ref.update(typedNotifications, (current) => [...current, payload]), - ), + clientInfo: { + name: "effect-acp-test", + version: "0.0.0", }, - }, - }); + }); + assert.equal(init.protocolVersion, 1); - const init = yield* client.initialize({ - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, - clientInfo: { - name: "effect-acp-test", - version: "0.0.0", - }, - }); - assert.equal(init.protocolVersion, 1); + yield* acp.authenticate({ methodId: "cursor_login" }); - yield* client.authenticate({ methodId: "cursor_login" }); + const session = yield* acp.createSession({ + cwd: process.cwd(), + mcpServers: [], + }); + assert.equal(session.sessionId, "mock-session-1"); - const session = yield* client.createSession({ - cwd: process.cwd(), - mcpServers: [], - }); - assert.equal(session.sessionId, "mock-session-1"); + const prompt = yield* acp.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "hello" }], + }); + assert.equal(prompt.stopReason, "end_turn"); + + const streamed = yield* Stream.runCollect(Stream.take(acp.notifications, 2)); + assert.equal(streamed.length, 2); + assert.equal(streamed[0]?._tag, "SessionUpdate"); + assert.equal(streamed[1]?._tag, "ElicitationComplete"); + assert.equal((yield* Ref.get(updates)).length, 1); + assert.equal((yield* Ref.get(elicitationCompletions)).length, 1); + assert.deepEqual(yield* Ref.get(typedRequests), [{ message: "hello from typed request" }]); + assert.deepEqual(yield* Ref.get(typedNotifications), [{ count: 2 }]); + + return yield* acp.request("x/echo", { + hello: "world", + }); + }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); - const prompt = yield* client.prompt({ - sessionId: session.sessionId, - prompt: [{ type: "text", text: "hello" }], - }); - assert.equal(prompt.stopReason, "end_turn"); - - const streamed = yield* Stream.runCollect(Stream.take(client.updates, 2)); - assert.equal(streamed.length, 2); - assert.equal(streamed[0]?._tag, "SessionUpdate"); - assert.equal(streamed[1]?._tag, "ElicitationComplete"); - assert.equal((yield* Ref.get(updates)).length, 1); - assert.equal((yield* Ref.get(elicitationCompletions)).length, 1); - assert.deepEqual(yield* Ref.get(typedRequests), [{ message: "hello from typed request" }]); - assert.deepEqual(yield* Ref.get(typedNotifications), [{ count: 2 }]); - - const ext = yield* client.extRequest("x/echo", { - hello: "world", - }); assert.deepEqual(ext, { echoedMethod: "x/echo", echoedParams: { @@ -132,59 +141,63 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { () => Effect.gen(function* () { const handle = yield* makeHandle({ ACP_MOCK_BAD_TYPED_REQUEST: "1" }); + const scope = yield* Scope.make(); + const acpLayer = AcpClient.layerFromChildProcessHandle(handle); + const context = yield* Layer.buildWithScope(acpLayer, scope); - const client = yield* AcpClient.fromChildProcess(handle, { - handlers: { - requestPermission: () => - Effect.succeed({ - outcome: { - outcome: "selected", - optionId: "allow", - }, - }), - elicitation: () => - Effect.succeed({ - action: { - action: "accept", - content: { - approved: true, - }, + const result = yield* Effect.gen(function* () { + const acp = yield* AcpClient.AcpConnection; + + yield* acp.handleRequestPermission(() => + Effect.succeed({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }), + ); + yield* acp.handleElicitation(() => + Effect.succeed({ + action: { + action: "accept", + content: { + approved: true, }, - }), - extRequests: { - "x/typed_request": AcpClient.defineExtRequest( - Schema.Struct({ message: Schema.String }), - () => Effect.succeed({ ok: true }), - ), + }, + }), + ); + yield* acp.handleExtRequest( + "x/typed_request", + Schema.Struct({ message: Schema.String }), + () => Effect.succeed({ ok: true }), + ); + + yield* acp.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, }, - }, - }); - - yield* client.initialize({ - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, - clientInfo: { - name: "effect-acp-test", - version: "0.0.0", - }, - }); + clientInfo: { + name: "effect-acp-test", + version: "0.0.0", + }, + }); - yield* client.authenticate({ methodId: "cursor_login" }); + yield* acp.authenticate({ methodId: "cursor_login" }); - const session = yield* client.createSession({ - cwd: process.cwd(), - mcpServers: [], - }); + const session = yield* acp.createSession({ + cwd: process.cwd(), + mcpServers: [], + }); - const result = yield* Effect.exit( - client.prompt({ - sessionId: session.sessionId, - prompt: [{ type: "text", text: "hello" }], - }), - ); + return yield* Effect.exit( + acp.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "hello" }], + }), + ); + }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); if (result._tag !== "Failure") { assert.fail("Expected prompt to fail for invalid typed extension payload"); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index cc6327ba8ef..1a895c80e17 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -1,162 +1,168 @@ import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; +import * as ServiceMap from "effect/ServiceMap"; import * as Sink from "effect/Sink"; import * as Stdio from "effect/Stdio"; +import * as Stream from "effect/Stream"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; import * as RpcServer from "effect/unstable/rpc/RpcServer"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { RpcClientError } from "effect/unstable/rpc"; import * as AcpError from "./errors"; import * as AcpProtocol from "./protocol"; import * as AcpRpcs from "./rpc"; -import * as AcpServer from "./server"; import * as AcpSchema from "./_generated/schema.gen"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { RpcClientError } from "effect/unstable/rpc"; +import * as AcpTerminal from "./terminal"; -export interface AcpExtensionRequestRegistration { - readonly payload: Schema.Codec; - readonly handler: (payload: A) => Effect.Effect; -} - -export interface AcpExtensionNotificationRegistration { - readonly payload: Schema.Codec; - readonly handler: (payload: A) => Effect.Effect; +export interface AcpConnectionOptions { + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; } -export const defineExtRequest = ( - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, -): AcpExtensionRequestRegistration => ({ payload, handler }); - -export const defineExtNotification = ( - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, -): AcpExtensionNotificationRegistration => ({ payload, handler }); - -export interface AcpClientHandlers { +export interface AcpConnectionShape { + readonly process: ChildProcessSpawner.ChildProcessHandle; /** - * Handles `session/request_permission`. + * Stream of inbound ACP notifications observed on the connection. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly notifications: Stream.Stream; + /** + * Registers a handler for `session/request_permission`. * @see https://agentclientprotocol.com/protocol/schema#session/request_permission */ - readonly requestPermission?: ( - request: AcpSchema.RequestPermissionRequest, - ) => Effect.Effect; + readonly handleRequestPermission: ( + handler: ( + request: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles `session/elicitation`. + * Registers a handler for `session/elicitation`. * @see https://agentclientprotocol.com/protocol/schema#session/elicitation */ - readonly elicitation?: ( - request: AcpSchema.ElicitationRequest, - ) => Effect.Effect; + readonly handleElicitation: ( + handler: ( + request: AcpSchema.ElicitationRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles `fs/read_text_file`. + * Registers a handler for `fs/read_text_file`. * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file */ - readonly readTextFile?: ( - request: AcpSchema.ReadTextFileRequest, - ) => Effect.Effect; + readonly handleReadTextFile: ( + handler: ( + request: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles `fs/write_text_file`. + * Registers a handler for `fs/write_text_file`. * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file */ - readonly writeTextFile?: ( - request: AcpSchema.WriteTextFileRequest, - ) => Effect.Effect; + readonly handleWriteTextFile: ( + handler: ( + request: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles `terminal/create`. + * Registers a handler for `terminal/create`. * @see https://agentclientprotocol.com/protocol/schema#terminal/create */ - readonly createTerminal?: ( - request: AcpSchema.CreateTerminalRequest, - ) => Effect.Effect; + readonly handleCreateTerminal: ( + handler: ( + request: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles `terminal/output`. + * Registers a handler for `terminal/output`. * @see https://agentclientprotocol.com/protocol/schema#terminal/output */ - readonly terminalOutput?: ( - request: AcpSchema.TerminalOutputRequest, - ) => Effect.Effect; + readonly handleTerminalOutput: ( + handler: ( + request: AcpSchema.TerminalOutputRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles `terminal/wait_for_exit`. + * Registers a handler for `terminal/wait_for_exit`. * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit */ - readonly terminalWaitForExit?: ( - request: AcpSchema.WaitForTerminalExitRequest, - ) => Effect.Effect; + readonly handleTerminalWaitForExit: ( + handler: ( + request: AcpSchema.WaitForTerminalExitRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles `terminal/kill`. + * Registers a handler for `terminal/kill`. * @see https://agentclientprotocol.com/protocol/schema#terminal/kill */ - readonly terminalKill?: ( - request: AcpSchema.KillTerminalRequest, - ) => Effect.Effect; + readonly handleTerminalKill: ( + handler: ( + request: AcpSchema.KillTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles `terminal/release`. + * Registers a handler for `terminal/release`. * @see https://agentclientprotocol.com/protocol/schema#terminal/release */ - readonly terminalRelease?: ( - request: AcpSchema.ReleaseTerminalRequest, - ) => Effect.Effect; + readonly handleTerminalRelease: ( + handler: ( + request: AcpSchema.ReleaseTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles `session/update` notifications from the agent. + * Registers a handler for `session/update`. * @see https://agentclientprotocol.com/protocol/schema#session/update */ - readonly sessionUpdate?: ( - notification: AcpSchema.SessionNotification, - ) => Effect.Effect; + readonly handleSessionUpdate: ( + handler: ( + notification: AcpSchema.SessionNotification, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles `session/elicitation/complete` notifications from the agent. + * Registers a handler for `session/elicitation/complete`. * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete */ - readonly elicitationComplete?: ( - notification: AcpSchema.ElicitationCompleteNotification, - ) => Effect.Effect; + readonly handleElicitationComplete: ( + handler: ( + notification: AcpSchema.ElicitationCompleteNotification, + ) => Effect.Effect, + ) => Effect.Effect; /** - * Handles extension requests outside the core ACP method set. + * Registers a fallback extension request handler. * @see https://agentclientprotocol.com/protocol/extensibility */ - readonly extRequest?: ( - method: string, - params: unknown, - ) => Effect.Effect; + readonly handleUnknownExtRequest: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; /** - * Handles extension requests outside the core ACP method set using typed payload decoders. + * Registers a fallback extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility */ - readonly extRequests?: Readonly>>; + readonly handleUnknownExtNotification: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; /** - * Handles extension notifications outside the core ACP method set. + * Registers a typed extension request handler. * @see https://agentclientprotocol.com/protocol/extensibility */ - readonly extNotification?: ( + readonly handleExtRequest: ( method: string, - params: unknown, - ) => Effect.Effect; + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; /** - * Handles extension notifications outside the core ACP method set using typed payload decoders. - */ - readonly extNotifications?: Readonly>>; -} - -export interface AcpClientConnectOptions { - readonly command: ChildProcess.Command; - readonly handlers?: AcpClientHandlers; - readonly logIncoming?: boolean; - readonly logOutgoing?: boolean; - readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; -} - -export interface AcpClientConnection { - readonly process: ChildProcessSpawner.ChildProcessHandle; - /** - * Stream of inbound ACP notifications observed on the connection. - * @see https://agentclientprotocol.com/protocol/schema#session/update + * Registers a typed extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility */ - readonly updates: Stream.Stream; - readonly server: AcpServer.AcpServerConnection; + readonly handleExtNotification: ( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; /** * Initializes the ACP session and negotiates capabilities. * @see https://agentclientprotocol.com/protocol/schema#initialize @@ -185,7 +191,8 @@ export interface AcpClientConnection { readonly createSession: ( payload: AcpSchema.NewSessionRequest, ) => Effect.Effect; - /** Loads a previously saved ACP session. + /** + * Loads a previously saved ACP session. * @see https://agentclientprotocol.com/protocol/schema#session/load */ readonly loadSession: ( @@ -258,99 +265,249 @@ export interface AcpClientConnection { * Sends an ACP extension request. * @see https://agentclientprotocol.com/protocol/extensibility */ - readonly extRequest: ( - method: string, - payload: unknown, - ) => Effect.Effect; + readonly request: (method: string, payload: unknown) => Effect.Effect; /** * Sends an ACP extension notification. * @see https://agentclientprotocol.com/protocol/extensibility */ - readonly extNotification: ( - method: string, - payload: unknown, + readonly notify: (method: string, payload: unknown) => Effect.Effect; + /** + * Requests client permission for an operation. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission + */ + readonly requestPermission: ( + payload: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect; + /** + * Requests structured user input from the client. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation + */ + readonly elicit: ( + payload: AcpSchema.ElicitationRequest, + ) => Effect.Effect; + /** + * Requests file contents from the client. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file + */ + readonly readTextFile: ( + payload: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect; + /** + * Writes a text file through the client. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + */ + readonly writeTextFile: ( + payload: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect; + /** + * Creates a terminal on the client side. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create + */ + readonly createTerminal: ( + payload: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect; + /** + * Sends a `session/update` notification to the client. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly sessionUpdate: ( + payload: AcpSchema.SessionNotification, + ) => Effect.Effect; + /** + * Sends a `session/elicitation/complete` notification to the client. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete + */ + readonly elicitationComplete: ( + payload: AcpSchema.ElicitationCompleteNotification, ) => Effect.Effect; } -export const fromChildProcess = Effect.fnUntraced(function* ( +export class AcpConnection extends ServiceMap.Service()( + "effect-acp/AcpConnection", +) {} + +interface AcpCoreRequestHandlers { + /** + * Handles `session/request_permission`. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission + */ + requestPermission?: ( + request: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect; + /** + * Handles `session/elicitation`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation + */ + elicitation?: ( + request: AcpSchema.ElicitationRequest, + ) => Effect.Effect; + /** + * Handles `fs/read_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file + */ + readTextFile?: ( + request: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect; + /** + * Handles `fs/write_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + */ + writeTextFile?: ( + request: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect; + /** + * Handles `terminal/create`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create + */ + createTerminal?: ( + request: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect; + /** + * Handles `terminal/output`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/output + */ + terminalOutput?: ( + request: AcpSchema.TerminalOutputRequest, + ) => Effect.Effect; + /** + * Handles `terminal/wait_for_exit`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit + */ + terminalWaitForExit?: ( + request: AcpSchema.WaitForTerminalExitRequest, + ) => Effect.Effect; + /** + * Handles `terminal/kill`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/kill + */ + terminalKill?: ( + request: AcpSchema.KillTerminalRequest, + ) => Effect.Effect; + /** + * Handles `terminal/release`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/release + */ + terminalRelease?: ( + request: AcpSchema.ReleaseTerminalRequest, + ) => Effect.Effect; +} + +interface AcpNotificationHandlers { + readonly sessionUpdate: Array< + (notification: AcpSchema.SessionNotification) => Effect.Effect + >; + readonly elicitationComplete: Array< + ( + notification: AcpSchema.ElicitationCompleteNotification, + ) => Effect.Effect + >; +} + +const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); +const textEncoder = new TextEncoder(); + +export const makeFromChildProcessHandle = Effect.fn("makeFromChildProcessHandle")(function* ( handle: ChildProcessSpawner.ChildProcessHandle, - options: { - readonly handlers?: AcpClientHandlers; - readonly logIncoming?: boolean; - readonly logOutgoing?: boolean; - readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; - } = {}, -): Effect.fn.Return { - const handlers = options.handlers ?? {}; + options: AcpConnectionOptions = {}, +): Effect.fn.Return { + const coreHandlers: AcpCoreRequestHandlers = {}; + const notificationHandlers: AcpNotificationHandlers = { + sessionUpdate: [], + elicitationComplete: [], + }; + const extRequestHandlers = new Map< + string, + (params: unknown) => Effect.Effect + >(); + const extNotificationHandlers = new Map< + string, + (params: unknown) => Effect.Effect + >(); + let unknownExtRequestHandler: + | ((method: string, params: unknown) => Effect.Effect) + | undefined; + let unknownExtNotificationHandler: + | ((method: string, params: unknown) => Effect.Effect) + | undefined; + + const dispatchNotification = (notification: AcpProtocol.AcpIncomingNotification) => { + switch (notification._tag) { + case "SessionUpdate": + return Effect.forEach( + notificationHandlers.sessionUpdate, + (handler) => handler(notification.params), + { discard: true }, + ); + case "ElicitationComplete": + return Effect.forEach( + notificationHandlers.elicitationComplete, + (handler) => handler(notification.params), + { discard: true }, + ); + case "ExtNotification": { + const handler = extNotificationHandlers.get(notification.method); + if (handler) { + return handler(notification.params); + } + return unknownExtNotificationHandler + ? unknownExtNotificationHandler(notification.method, notification.params) + : Effect.void; + } + } + }; + + const dispatchExtRequest = (method: string, params: unknown) => { + const handler = extRequestHandlers.get(method); + if (handler) { + return handler(params); + } + return unknownExtRequestHandler + ? unknownExtRequestHandler(method, params) + : Effect.fail(AcpError.AcpRequestError.methodNotFound(method)); + }; + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio: makeStdioFromChildProcess(handle), - processExit: handle.exitCode.pipe( - Effect.map(Number), - Effect.mapError( - (cause) => - new AcpError.AcpProcessExitedError({ - cause, - }), - ), - ), serverRequestMethods: new Set(AcpRpcs.ClientRpcs.requests.keys()), ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}), ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}), ...(options.logger ? { logger: options.logger } : {}), - onNotification: (notification) => { - switch (notification._tag) { - case "SessionUpdate": - return handlers.sessionUpdate ? handlers.sessionUpdate(notification.params) : Effect.void; - case "ElicitationComplete": - return handlers.elicitationComplete - ? handlers.elicitationComplete(notification.params) - : Effect.void; - case "ExtNotification": - return runExtNotificationHandler( - handlers.extNotifications?.[notification.method], - handlers.extNotification, - notification.method, - notification.params, - ); - } - }, - ...(handlers.extRequest || handlers.extRequests - ? { - onExtRequest: (method: string, params: unknown) => - runExtRequestHandler( - handlers.extRequests?.[method], - handlers.extRequest, - method, - params, - ), - } - : {}), + onNotification: dispatchNotification, + onExtRequest: dispatchExtRequest, }); const clientHandlerLayer = AcpRpcs.ClientRpcs.toLayer( AcpRpcs.ClientRpcs.of({ [CLIENT_METHODS.session_request_permission]: (payload) => - runHandler(handlers.requestPermission, payload, CLIENT_METHODS.session_request_permission), + runHandler( + coreHandlers.requestPermission, + payload, + CLIENT_METHODS.session_request_permission, + ), [CLIENT_METHODS.session_elicitation]: (payload) => - runHandler(handlers.elicitation, payload, CLIENT_METHODS.session_elicitation), + runHandler(coreHandlers.elicitation, payload, CLIENT_METHODS.session_elicitation), [CLIENT_METHODS.fs_read_text_file]: (payload) => - runHandler(handlers.readTextFile, payload, CLIENT_METHODS.fs_read_text_file), + runHandler(coreHandlers.readTextFile, payload, CLIENT_METHODS.fs_read_text_file), [CLIENT_METHODS.fs_write_text_file]: (payload) => - runHandler(handlers.writeTextFile, payload, CLIENT_METHODS.fs_write_text_file).pipe( + runHandler(coreHandlers.writeTextFile, payload, CLIENT_METHODS.fs_write_text_file).pipe( Effect.map((result) => result ?? {}), ), [CLIENT_METHODS.terminal_create]: (payload) => - runHandler(handlers.createTerminal, payload, CLIENT_METHODS.terminal_create), + runHandler(coreHandlers.createTerminal, payload, CLIENT_METHODS.terminal_create), [CLIENT_METHODS.terminal_output]: (payload) => - runHandler(handlers.terminalOutput, payload, CLIENT_METHODS.terminal_output), + runHandler(coreHandlers.terminalOutput, payload, CLIENT_METHODS.terminal_output), [CLIENT_METHODS.terminal_wait_for_exit]: (payload) => - runHandler(handlers.terminalWaitForExit, payload, CLIENT_METHODS.terminal_wait_for_exit), + runHandler( + coreHandlers.terminalWaitForExit, + payload, + CLIENT_METHODS.terminal_wait_for_exit, + ), [CLIENT_METHODS.terminal_kill]: (payload) => - runHandler(handlers.terminalKill, payload, CLIENT_METHODS.terminal_kill).pipe( + runHandler(coreHandlers.terminalKill, payload, CLIENT_METHODS.terminal_kill).pipe( Effect.map((result) => result ?? {}), ), [CLIENT_METHODS.terminal_release]: (payload) => - runHandler(handlers.terminalRelease, payload, CLIENT_METHODS.terminal_release).pipe( + runHandler(coreHandlers.terminalRelease, payload, CLIENT_METHODS.terminal_release).pipe( Effect.map((result) => result ?? {}), ), }), @@ -381,12 +538,90 @@ export const fromChildProcess = Effect.fnUntraced(function* ( ), ); - const server = AcpServer.makeAcpServerConnection(transport); + const request =
(method: string, payload: unknown) => + transport.request(method, payload).pipe(Effect.map((value) => value as A)); - return { + return AcpConnection.of({ process: handle, - updates: transport.notifications.incoming, - server, + notifications: transport.incoming, + handleRequestPermission: (handler) => + Effect.suspend(() => { + coreHandlers.requestPermission = handler; + return Effect.void; + }), + handleElicitation: (handler) => + Effect.suspend(() => { + coreHandlers.elicitation = handler; + return Effect.void; + }), + handleReadTextFile: (handler) => + Effect.suspend(() => { + coreHandlers.readTextFile = handler; + return Effect.void; + }), + handleWriteTextFile: (handler) => + Effect.suspend(() => { + coreHandlers.writeTextFile = handler; + return Effect.void; + }), + handleCreateTerminal: (handler) => + Effect.suspend(() => { + coreHandlers.createTerminal = handler; + return Effect.void; + }), + handleTerminalOutput: (handler) => + Effect.suspend(() => { + coreHandlers.terminalOutput = handler; + return Effect.void; + }), + handleTerminalWaitForExit: (handler) => + Effect.suspend(() => { + coreHandlers.terminalWaitForExit = handler; + return Effect.void; + }), + handleTerminalKill: (handler) => + Effect.suspend(() => { + coreHandlers.terminalKill = handler; + return Effect.void; + }), + handleTerminalRelease: (handler) => + Effect.suspend(() => { + coreHandlers.terminalRelease = handler; + return Effect.void; + }), + handleSessionUpdate: (handler) => + Effect.suspend(() => { + notificationHandlers.sessionUpdate.push(handler); + return Effect.void; + }), + handleElicitationComplete: (handler) => + Effect.suspend(() => { + notificationHandlers.elicitationComplete.push(handler); + return Effect.void; + }), + handleUnknownExtRequest: (handler) => + Effect.suspend(() => { + unknownExtRequestHandler = handler; + return Effect.void; + }), + handleUnknownExtNotification: (handler) => + Effect.suspend(() => { + unknownExtNotificationHandler = handler; + return Effect.void; + }), + handleExtRequest: (method, payload, handler) => + Effect.suspend(() => { + extRequestHandlers.set(method, decodeExtRequestRegistration(method, payload, handler)); + return Effect.void; + }), + handleExtNotification: (method, payload, handler) => + Effect.suspend(() => { + extNotificationHandlers.set( + method, + decodeExtNotificationRegistration(method, payload, handler), + ); + return Effect.void; + }), initialize: (payload) => callRpc(rpc[AGENT_METHODS.initialize](payload)), authenticate: (payload) => callRpc(rpc[AGENT_METHODS.authenticate](payload)), logout: (payload) => callRpc(rpc[AGENT_METHODS.logout](payload)), @@ -401,12 +636,62 @@ export const fromChildProcess = Effect.fnUntraced(function* ( setSessionConfigOption: (payload) => callRpc(rpc[AGENT_METHODS.session_set_config_option](payload)), prompt: (payload) => callRpc(rpc[AGENT_METHODS.session_prompt](payload)), - cancel: (payload) => transport.notifications.sendSessionCancel(payload), - extRequest: transport.sendRequest, - extNotification: transport.notifications.sendExtNotification, - } satisfies AcpClientConnection; + cancel: (payload) => transport.notify("session/cancel", payload), + request: transport.request, + notify: transport.notify, + requestPermission: (payload) => + request( + CLIENT_METHODS.session_request_permission, + payload, + ), + elicit: (payload) => + request(CLIENT_METHODS.session_elicitation, payload), + readTextFile: (payload) => + request(CLIENT_METHODS.fs_read_text_file, payload), + writeTextFile: (payload) => + request(CLIENT_METHODS.fs_write_text_file, payload).pipe( + Effect.map((response) => response ?? {}), + ), + createTerminal: (payload) => + request(CLIENT_METHODS.terminal_create, payload).pipe( + Effect.map((response) => + AcpTerminal.makeTerminal({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + output: request(CLIENT_METHODS.terminal_output, { + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + waitForExit: request( + CLIENT_METHODS.terminal_wait_for_exit, + { + sessionId: payload.sessionId, + terminalId: response.terminalId, + }, + ), + kill: request(CLIENT_METHODS.terminal_kill, { + sessionId: payload.sessionId, + terminalId: response.terminalId, + }).pipe(Effect.map((result) => result ?? {})), + release: request(CLIENT_METHODS.terminal_release, { + sessionId: payload.sessionId, + terminalId: response.terminalId, + }).pipe(Effect.map((result) => result ?? {})), + }), + ), + ), + sessionUpdate: (payload) => transport.notify(CLIENT_METHODS.session_update, payload), + elicitationComplete: (payload) => + transport.notify(CLIENT_METHODS.session_elicitation_complete, payload), + } satisfies AcpConnectionShape); }); +export const layerFromChildProcessHandle = ( + handle: ChildProcessSpawner.ChildProcessHandle, + options: AcpConnectionOptions = {}, +): Layer.Layer => + Layer.effect(AcpConnection, makeFromChildProcessHandle(handle, options)); + const runHandler = Effect.fnUntraced(function* ( handler: ((payload: A) => Effect.Effect) | undefined, payload: A, @@ -416,51 +701,38 @@ const runHandler = Effect.fnUntraced(function* ( return yield* AcpError.AcpRequestError.methodNotFound(method); } return yield* handler(payload).pipe( - Effect.mapError((error) => { - return Schema.is(AcpError.AcpRequestError)(error) + Effect.mapError((error) => + Schema.is(AcpError.AcpRequestError)(error) ? error.toProtocolError() - : AcpError.AcpRequestError.internalError(error.message).toProtocolError(); - }), + : AcpError.AcpRequestError.internalError(error.message).toProtocolError(), + ), ); }); -const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); - -const runExtRequestHandler = ( - registration: AcpExtensionRequestRegistration | undefined, - fallback: - | ((method: string, params: unknown) => Effect.Effect) - | undefined, +function decodeExtRequestRegistration( method: string, - params: unknown, -): Effect.Effect => { - if (registration) { - return Schema.decodeUnknownEffect(registration.payload)(params).pipe( + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, +) { + return (params: unknown): Effect.Effect => + Schema.decodeUnknownEffect(payload)(params).pipe( Effect.mapError((error) => AcpError.AcpRequestError.invalidParams( `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, { issue: error.issue }, ), ), - Effect.flatMap((payload) => registration.handler(payload)), + Effect.flatMap((decoded) => handler(decoded)), ); - } - if (fallback) { - return fallback(method, params); - } - return Effect.fail(AcpError.AcpRequestError.methodNotFound(method)); -}; +} -const runExtNotificationHandler = ( - registration: AcpExtensionNotificationRegistration | undefined, - fallback: - | ((method: string, params: unknown) => Effect.Effect) - | undefined, +function decodeExtNotificationRegistration( method: string, - params: unknown, -): Effect.Effect => { - if (registration) { - return Schema.decodeUnknownEffect(registration.payload)(params).pipe( + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, +) { + return (params: unknown): Effect.Effect => + Schema.decodeUnknownEffect(payload)(params).pipe( Effect.mapError( (error) => new AcpError.AcpProtocolParseError({ @@ -468,13 +740,10 @@ const runExtNotificationHandler = ( cause: error, }), ), - Effect.flatMap((payload) => registration.handler(payload)), + Effect.flatMap((decoded) => handler(decoded)), ); - } - return fallback ? fallback(method, params) : Effect.void; -}; +} -const textEncoder = new TextEncoder(); function makeStdioFromChildProcess(handle: ChildProcessSpawner.ChildProcessHandle): Stdio.Stdio { return Stdio.make({ args: Effect.succeed([]), diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 1cb25105c27..b105b04865f 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -100,14 +100,14 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { const notifications = yield* Deferred.make>(); - yield* transport.notifications.incoming.pipe( + yield* transport.incoming.pipe( Stream.take(2), Stream.runCollect, Effect.flatMap((notificationChunk) => Deferred.succeed(notifications, notificationChunk)), Effect.forkScoped, ); - yield* transport.notifications.sendSessionCancel({ sessionId: "session-1" }); + yield* transport.notify("session/cancel", { sessionId: "session-1" }); const outbound = yield* Queue.take(output); assert.deepEqual(decodeJson(outbound), { jsonrpc: "2.0", @@ -175,7 +175,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), }); - yield* transport.notifications.sendSessionCancel({ sessionId: "session-1" }); + yield* transport.notify("session/cancel", { sessionId: "session-1" }); assert.deepEqual(events, [ { @@ -209,17 +209,13 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { serverRequestMethods: new Set(), }); - const bigintError = yield* transport.notifications - .sendExtNotification("x/test", 1n) - .pipe(Effect.flip); + const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); assert.equal(bigintError.detail, "Failed to encode ACP message"); const circular: Record = {}; circular.self = circular; - const circularError = yield* transport.notifications - .sendExtNotification("x/test", circular) - .pipe(Effect.flip); + const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, AcpError.AcpProtocolParseError); assert.equal(circularError.detail, "Failed to encode ACP message"); }), @@ -234,7 +230,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }); const response = yield* transport - .sendRequest("x/test", { hello: "world" }) + .request("x/test", { hello: "world" }) .pipe(Effect.forkScoped); const outbound = yield* Queue.take(output); assert.deepEqual(decodeJson(outbound), { @@ -279,7 +275,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { .pipe(Effect.forkScoped); const response = yield* transport - .sendRequest("x/test", { hello: "world" }) + .request("x/test", { hello: "world" }) .pipe(Effect.forkScoped); const outbound = yield* Queue.take(output); assert.deepEqual(decodeJson(outbound), { @@ -392,7 +388,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }); const response = yield* transport - .sendRequest("x/test", { hello: "world" }) + .request("x/test", { hello: "world" }) .pipe(Effect.forkScoped); yield* Queue.take(output); yield* Queue.end(input); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 2eefc1807fd..51ae5553c0f 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -62,20 +62,9 @@ export interface AcpPatchedProtocolOptions { export interface AcpPatchedProtocol { readonly clientProtocol: RpcClient.Protocol["Service"]; readonly serverProtocol: RpcServer.Protocol["Service"]; - readonly notifications: { - readonly incoming: Stream.Stream; - readonly sendSessionCancel: ( - payload: typeof AcpSchema.CancelNotification.Type, - ) => Effect.Effect; - readonly sendExtNotification: ( - method: string, - payload: unknown, - ) => Effect.Effect; - }; - readonly sendRequest: ( - method: string, - payload: unknown, - ) => Effect.Effect; + readonly incoming: Stream.Stream; + readonly request: (method: string, payload: unknown) => Effect.Effect; + readonly notify: (method: string, payload: unknown) => Effect.Effect; } const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification); @@ -84,471 +73,467 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( ); const parserFactory = RpcSerialization.ndJsonRpc(); -export const makeAcpPatchedProtocol = ( +export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( options: AcpPatchedProtocolOptions, -): Effect.Effect => - Effect.gen(function* () { - const parser = parserFactory.makeUnsafe(); - const serverQueue = yield* Queue.unbounded(); - const clientQueue = yield* Queue.unbounded(); - const notificationQueue = yield* Queue.unbounded(); - const disconnects = yield* Queue.unbounded(); - const outgoing = yield* Queue.unbounded>(); - const nextRequestId = yield* Ref.make(1n); - const terminationHandled = yield* Ref.make(false); - const extPending = yield* Ref.make( - new Map>(), +): Effect.fn.Return { + const parser = parserFactory.makeUnsafe(); + const serverQueue = yield* Queue.unbounded(); + const clientQueue = yield* Queue.unbounded(); + const notificationQueue = yield* Queue.unbounded(); + const disconnects = yield* Queue.unbounded(); + const outgoing = yield* Queue.unbounded>(); + const nextRequestId = yield* Ref.make(1n); + const terminationHandled = yield* Ref.make(false); + const extPending = yield* Ref.make( + new Map>(), + ); + + const logProtocol = (event: AcpProtocolLogEvent) => { + if (event.direction === "incoming" && !options.logIncoming) { + return Effect.void; + } + if (event.direction === "outgoing" && !options.logOutgoing) { + return Effect.void; + } + return ( + options.logger?.(event) ?? + Effect.logDebug("ACP protocol event").pipe(Effect.annotateLogs({ event })) ); + }; - const logProtocol = (event: AcpProtocolLogEvent) => { - if (event.direction === "incoming" && !options.logIncoming) { - return Effect.void; + const offerOutgoing = (message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded) => + Effect.try({ + try: () => parser.encode(message), + catch: (cause) => + new AcpError.AcpProtocolParseError({ + detail: "Failed to encode ACP message", + cause, + }), + }).pipe( + Effect.tap(() => + logProtocol({ + direction: "outgoing", + stage: "decoded", + payload: message, + }), + ), + Effect.flatMap((encoded) => + encoded === undefined + ? Effect.void + : logProtocol({ + direction: "outgoing", + stage: "raw", + payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded), + }).pipe(Effect.flatMap(() => Queue.offer(outgoing, encoded).pipe(Effect.asVoid))), + ), + ); + + const resolveExtPending = ( + requestId: string, + onFound: (deferred: Deferred.Deferred) => Effect.Effect, + ) => + Ref.modify(extPending, (pending) => { + const deferred = pending.get(requestId); + if (!deferred) { + return [Effect.void, pending] as const; } - if (event.direction === "outgoing" && !options.logOutgoing) { - return Effect.void; + const next = new Map(pending); + next.delete(requestId); + return [onFound(deferred), next] as const; + }).pipe(Effect.flatten); + + const removeExtPending = (requestId: string) => + Ref.update(extPending, (pending) => { + if (!pending.has(requestId)) { + return pending; } - return ( - options.logger?.(event) ?? - Effect.logDebug("ACP protocol event").pipe(Effect.annotateLogs({ event })) - ); - }; - - const offerOutgoing = (message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded) => - Effect.try({ - try: () => parser.encode(message), - catch: (cause) => - new AcpError.AcpProtocolParseError({ - detail: "Failed to encode ACP message", - cause, - }), - }).pipe( - Effect.tap(() => - logProtocol({ - direction: "outgoing", - stage: "decoded", - payload: message, - }), - ), - Effect.flatMap((encoded) => - encoded === undefined - ? Effect.void - : logProtocol({ - direction: "outgoing", - stage: "raw", - payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded), - }).pipe(Effect.flatMap(() => Queue.offer(outgoing, encoded).pipe(Effect.asVoid))), - ), - ); - - const resolveExtPending = ( - requestId: string, - onFound: (deferred: Deferred.Deferred) => Effect.Effect, - ) => - Ref.modify(extPending, (pending) => { - const deferred = pending.get(requestId); - if (!deferred) { - return [Effect.void, pending] as const; - } - const next = new Map(pending); - next.delete(requestId); - return [onFound(deferred), next] as const; - }).pipe(Effect.flatten); - - const removeExtPending = (requestId: string) => - Ref.update(extPending, (pending) => { - if (!pending.has(requestId)) { - return pending; - } - const next = new Map(pending); - next.delete(requestId); - return next; - }); + const next = new Map(pending); + next.delete(requestId); + return next; + }); - const completeExtPendingFailure = (requestId: string, error: AcpError.AcpError) => - resolveExtPending(requestId, (deferred) => Deferred.fail(deferred, error)); + const completeExtPendingFailure = (requestId: string, error: AcpError.AcpError) => + resolveExtPending(requestId, (deferred) => Deferred.fail(deferred, error)); - const completeExtPendingSuccess = (requestId: string, value: unknown) => - resolveExtPending(requestId, (deferred) => Deferred.succeed(deferred, value)); + const completeExtPendingSuccess = (requestId: string, value: unknown) => + resolveExtPending(requestId, (deferred) => Deferred.succeed(deferred, value)); - const failAllExtPending = (error: AcpError.AcpError) => - Ref.get(extPending).pipe( - Effect.flatMap((pending) => - Effect.forEach([...pending.values()], (deferred) => Deferred.fail(deferred, error), { - discard: true, - }), - ), - Effect.andThen(Ref.set(extPending, new Map())), - ); + const failAllExtPending = (error: AcpError.AcpError) => + Ref.get(extPending).pipe( + Effect.flatMap((pending) => + Effect.forEach([...pending.values()], (deferred) => Deferred.fail(deferred, error), { + discard: true, + }), + ), + Effect.andThen(Ref.set(extPending, new Map())), + ); - const dispatchNotification = (notification: AcpIncomingNotification) => - Queue.offer(notificationQueue, notification).pipe( - Effect.andThen( - options.onNotification - ? options.onNotification(notification).pipe(Effect.catch(() => Effect.void)) - : Effect.void, - ), - Effect.asVoid, - ); + const dispatchNotification = (notification: AcpIncomingNotification) => + Queue.offer(notificationQueue, notification).pipe( + Effect.andThen( + options.onNotification + ? options.onNotification(notification).pipe(Effect.catch(() => Effect.void)) + : Effect.void, + ), + Effect.asVoid, + ); - const emitClientProtocolError = (error: AcpError.AcpError) => - Queue.offer(clientQueue, { - _tag: "ClientProtocolError", - error: new RpcClientError.RpcClientError({ - reason: new RpcClientError.RpcClientDefect({ - message: error.message, - cause: error, - }), + const emitClientProtocolError = (error: AcpError.AcpError) => + Queue.offer(clientQueue, { + _tag: "ClientProtocolError", + error: new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, }), - }).pipe(Effect.asVoid); + }), + }).pipe(Effect.asVoid); - const handleTermination = ( - classify: () => Effect.Effect< - | { - readonly error: AcpError.AcpError; - readonly processExitError?: AcpError.AcpProcessExitedError | undefined; - } - | undefined - >, - ) => - Ref.modify(terminationHandled, (handled) => { - if (handled) { - return [Effect.void, true] as const; + const handleTermination = ( + classify: () => Effect.Effect< + | { + readonly error: AcpError.AcpError; + readonly processExitError?: AcpError.AcpProcessExitedError | undefined; } - return [ - Effect.gen(function* () { - yield* Queue.offer(disconnects, 0); - const terminated = yield* classify(); - if (!terminated) { - return; - } - yield* failAllExtPending(terminated.error); - yield* emitClientProtocolError(terminated.error); - if (terminated.processExitError && options.onProcessExit) { - yield* options.onProcessExit(terminated.processExitError); - } - }), - true, - ] as const; - }).pipe(Effect.flatten); - - const respondWithSuccess = (requestId: string, value: unknown) => - offerOutgoing({ - _tag: "Exit", - requestId, - exit: { - _tag: "Success", - value, - }, - }); - - const respondWithError = (requestId: string, error: AcpError.AcpRequestError) => - offerOutgoing({ - _tag: "Exit", - requestId, - exit: { - _tag: "Failure", - cause: [ - { - _tag: "Fail", - error: error.toProtocolError(), - }, - ], - }, - }); - - const handleExtRequest = (message: RpcMessage.RequestEncoded) => { - if (!options.onExtRequest) { - return respondWithError(message.id, AcpError.AcpRequestError.methodNotFound(message.tag)); + | undefined + >, + ) => + Ref.modify(terminationHandled, (handled) => { + if (handled) { + return [Effect.void, true] as const; } - return options.onExtRequest(message.tag, message.payload).pipe( - Effect.matchEffect({ - onFailure: (error) => respondWithError(message.id, normalizeToRequestError(error)), - onSuccess: (value) => respondWithSuccess(message.id, value), + return [ + Effect.gen(function* () { + yield* Queue.offer(disconnects, 0); + const terminated = yield* classify(); + if (!terminated) { + return; + } + yield* failAllExtPending(terminated.error); + yield* emitClientProtocolError(terminated.error); + if (terminated.processExitError && options.onProcessExit) { + yield* options.onProcessExit(terminated.processExitError); + } }), - ); - }; + true, + ] as const; + }).pipe(Effect.flatten); + + const respondWithSuccess = (requestId: string, value: unknown) => + offerOutgoing({ + _tag: "Exit", + requestId, + exit: { + _tag: "Success", + value, + }, + }); - const handleRequestEncoded = (message: RpcMessage.RequestEncoded) => { - if (message.id === "") { - if (message.tag === CLIENT_METHODS.session_update) { - return decodeSessionUpdate(message.payload).pipe( - Effect.map( - (params) => - ({ - _tag: "SessionUpdate", - method: CLIENT_METHODS.session_update, - params, - }) satisfies AcpIncomingNotification, - ), - Effect.mapError( - (cause) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${CLIENT_METHODS.session_update} notification payload`, - cause, - }), - ), - Effect.flatMap(dispatchNotification), - ); - } - if (message.tag === CLIENT_METHODS.session_elicitation_complete) { - return decodeElicitationComplete(message.payload).pipe( - Effect.map( - (params) => - ({ - _tag: "ElicitationComplete", - method: CLIENT_METHODS.session_elicitation_complete, - params, - }) satisfies AcpIncomingNotification, - ), - Effect.mapError( - (cause) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${CLIENT_METHODS.session_elicitation_complete} notification payload`, - cause, - }), - ), - Effect.flatMap(dispatchNotification), - ); - } - return dispatchNotification({ - _tag: "ExtNotification", - method: message.tag, - params: message.payload, - }); - } + const respondWithError = (requestId: string, error: AcpError.AcpRequestError) => + offerOutgoing({ + _tag: "Exit", + requestId, + exit: { + _tag: "Failure", + cause: [ + { + _tag: "Fail", + error: error.toProtocolError(), + }, + ], + }, + }); + + const handleExtRequest = (message: RpcMessage.RequestEncoded) => { + if (!options.onExtRequest) { + return respondWithError(message.id, AcpError.AcpRequestError.methodNotFound(message.tag)); + } + return options.onExtRequest(message.tag, message.payload).pipe( + Effect.matchEffect({ + onFailure: (error) => respondWithError(message.id, normalizeToRequestError(error)), + onSuccess: (value) => respondWithSuccess(message.id, value), + }), + ); + }; - if (!options.serverRequestMethods.has(message.tag)) { - return handleExtRequest(message).pipe( - Effect.catch(() => - respondWithError(message.id, AcpError.AcpRequestError.internalError()), + const handleRequestEncoded = (message: RpcMessage.RequestEncoded) => { + if (message.id === "") { + if (message.tag === CLIENT_METHODS.session_update) { + return decodeSessionUpdate(message.payload).pipe( + Effect.map( + (params) => + ({ + _tag: "SessionUpdate", + method: CLIENT_METHODS.session_update, + params, + }) satisfies AcpIncomingNotification, + ), + Effect.mapError( + (cause) => + new AcpError.AcpProtocolParseError({ + detail: `Invalid ${CLIENT_METHODS.session_update} notification payload`, + cause, + }), + ), + Effect.flatMap(dispatchNotification), + ); + } + if (message.tag === CLIENT_METHODS.session_elicitation_complete) { + return decodeElicitationComplete(message.payload).pipe( + Effect.map( + (params) => + ({ + _tag: "ElicitationComplete", + method: CLIENT_METHODS.session_elicitation_complete, + params, + }) satisfies AcpIncomingNotification, + ), + Effect.mapError( + (cause) => + new AcpError.AcpProtocolParseError({ + detail: `Invalid ${CLIENT_METHODS.session_elicitation_complete} notification payload`, + cause, + }), ), - Effect.asVoid, + Effect.flatMap(dispatchNotification), ); } + return dispatchNotification({ + _tag: "ExtNotification", + method: message.tag, + params: message.payload, + }); + } - return Queue.offer(serverQueue, message).pipe(Effect.asVoid); - }; + if (!options.serverRequestMethods.has(message.tag)) { + return handleExtRequest(message).pipe( + Effect.catch(() => respondWithError(message.id, AcpError.AcpRequestError.internalError())), + Effect.asVoid, + ); + } - const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => - Ref.get(extPending).pipe( - Effect.flatMap((pending) => { - if (!pending.has(message.requestId)) { - return Queue.offer(clientQueue, message).pipe(Effect.asVoid); - } - if (message.exit._tag === "Success") { - return completeExtPendingSuccess(message.requestId, message.exit.value); - } - const failure = message.exit.cause.find((entry) => entry._tag === "Fail"); - if (failure && isProtocolError(failure.error)) { - return completeExtPendingFailure( - message.requestId, - AcpError.AcpRequestError.fromProtocolError(failure.error), - ); - } + return Queue.offer(serverQueue, message).pipe(Effect.asVoid); + }; + + const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => + Ref.get(extPending).pipe( + Effect.flatMap((pending) => { + if (!pending.has(message.requestId)) { + return Queue.offer(clientQueue, message).pipe(Effect.asVoid); + } + if (message.exit._tag === "Success") { + return completeExtPendingSuccess(message.requestId, message.exit.value); + } + const failure = message.exit.cause.find((entry) => entry._tag === "Fail"); + if (failure && isProtocolError(failure.error)) { return completeExtPendingFailure( message.requestId, - AcpError.AcpRequestError.internalError("Extension request failed"), - ); - }), - ); - - const routeDecodedMessage = ( - message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, - ): Effect.Effect => { - switch (message._tag) { - case "Request": - return handleRequestEncoded(message); - case "Exit": - return handleExitEncoded(message); - case "Chunk": - return Ref.get(extPending).pipe( - Effect.flatMap((pending) => - pending.has(message.requestId) - ? completeExtPendingFailure( - message.requestId, - AcpError.AcpRequestError.internalError( - "Streaming extension responses are not supported", - ), - ) - : Queue.offer(clientQueue, message).pipe(Effect.asVoid), - ), + AcpError.AcpRequestError.fromProtocolError(failure.error), ); - case "Defect": - case "ClientProtocolError": - case "Pong": - return Queue.offer(clientQueue, message).pipe(Effect.asVoid); - case "Ack": - case "Interrupt": - case "Ping": - case "Eof": - return Queue.offer(serverQueue, message).pipe(Effect.asVoid); - } - }; + } + return completeExtPendingFailure( + message.requestId, + AcpError.AcpRequestError.internalError("Extension request failed"), + ); + }), + ); - yield* options.stdio.stdin.pipe( - Stream.runForEach((data) => - logProtocol({ - direction: "incoming", - stage: "raw", - payload: typeof data === "string" ? data : new TextDecoder().decode(data), - }).pipe( - Effect.flatMap(() => - Effect.try({ - try: () => - parser.decode(data) as ReadonlyArray< - RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded - >, - catch: (cause) => - new AcpError.AcpProtocolParseError({ - detail: "Failed to decode ACP wire message", - cause, - }), - }), - ), - Effect.tap((messages) => - logProtocol({ - direction: "incoming", - stage: "decoded", - payload: messages, - }), - ), - Effect.tapErrorTag("AcpProtocolParseError", (error) => - logProtocol({ - direction: "incoming", - stage: "decode_failed", - payload: { - detail: error.detail, - cause: error.cause, - }, - }), - ), - Effect.flatMap((messages) => - Effect.forEach(messages, routeDecodedMessage, { - discard: true, - }), + const routeDecodedMessage = ( + message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, + ): Effect.Effect => { + switch (message._tag) { + case "Request": + return handleRequestEncoded(message); + case "Exit": + return handleExitEncoded(message); + case "Chunk": + return Ref.get(extPending).pipe( + Effect.flatMap((pending) => + pending.has(message.requestId) + ? completeExtPendingFailure( + message.requestId, + AcpError.AcpRequestError.internalError( + "Streaming extension responses are not supported", + ), + ) + : Queue.offer(clientQueue, message).pipe(Effect.asVoid), ), + ); + case "Defect": + case "ClientProtocolError": + case "Pong": + return Queue.offer(clientQueue, message).pipe(Effect.asVoid); + case "Ack": + case "Interrupt": + case "Ping": + case "Eof": + return Queue.offer(serverQueue, message).pipe(Effect.asVoid); + } + }; + + yield* options.stdio.stdin.pipe( + Stream.runForEach((data) => + logProtocol({ + direction: "incoming", + stage: "raw", + payload: typeof data === "string" ? data : new TextDecoder().decode(data), + }).pipe( + Effect.flatMap(() => + Effect.try({ + try: () => + parser.decode(data) as ReadonlyArray< + RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded + >, + catch: (cause) => + new AcpError.AcpProtocolParseError({ + detail: "Failed to decode ACP wire message", + cause, + }), + }), + ), + Effect.tap((messages) => + logProtocol({ + direction: "incoming", + stage: "decoded", + payload: messages, + }), + ), + Effect.tapErrorTag("AcpProtocolParseError", (error) => + logProtocol({ + direction: "incoming", + stage: "decode_failed", + payload: { + detail: error.detail, + cause: error.cause, + }, + }), + ), + Effect.flatMap((messages) => + Effect.forEach(messages, routeDecodedMessage, { + discard: true, + }), ), ), - Effect.matchEffect({ - onFailure: (error) => { - const normalized: AcpError.AcpError = Schema.is(AcpError.AcpError)(error) - ? error - : new AcpError.AcpTransportError({ - detail: error instanceof Error ? error.message : String(error), - cause: error, - }); - return handleTermination(() => Effect.succeed({ error: normalized })); - }, - onSuccess: () => - handleTermination(() => - options.processExit - ? options.processExit.pipe( - Effect.match({ - onFailure: (processExitError) => - ({ - error: processExitError, - processExitError, - }) as const, - onSuccess: (code) => { - const processExitError = - code === null - ? new AcpError.AcpProcessExitedError({}) - : new AcpError.AcpProcessExitedError({ code }); - return { - error: processExitError, - processExitError, - } as const; - }, - }), - ) - : Effect.succeed({ - error: new AcpError.AcpTransportError({ - detail: "ACP input stream ended", - cause: new Error("ACP input stream ended"), - }), + ), + Effect.matchEffect({ + onFailure: (error) => { + const normalized: AcpError.AcpError = Schema.is(AcpError.AcpError)(error) + ? error + : new AcpError.AcpTransportError({ + detail: error instanceof Error ? error.message : String(error), + cause: error, + }); + return handleTermination(() => Effect.succeed({ error: normalized })); + }, + onSuccess: () => + handleTermination(() => + options.processExit + ? options.processExit.pipe( + Effect.match({ + onFailure: (processExitError) => + ({ + error: processExitError, + processExitError, + }) as const, + onSuccess: (code) => { + const processExitError = + code === null + ? new AcpError.AcpProcessExitedError({}) + : new AcpError.AcpProcessExitedError({ code }); + return { + error: processExitError, + processExitError, + } as const; + }, }), - ), - }), - Effect.forkScoped, - ); - - yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped); - - const clientProtocol = RpcClient.Protocol.of({ - run: (f) => - Stream.fromQueue(clientQueue).pipe( - Stream.runForEach((message) => f(message)), - Effect.forever, + ) + : Effect.succeed({ + error: new AcpError.AcpTransportError({ + detail: "ACP input stream ended", + cause: new Error("ACP input stream ended"), + }), + }), ), - send: (request) => offerOutgoing(request).pipe(Effect.mapError(toRpcClientError)), - supportsAck: true, - supportsTransferables: false, - }); + }), + Effect.forkScoped, + ); - const serverProtocol = RpcServer.Protocol.of({ - run: (f) => - Stream.fromQueue(serverQueue).pipe( - Stream.runForEach((message) => f(0, message)), - Effect.forever, - ), - disconnects, - send: (_clientId, response) => offerOutgoing(response).pipe(Effect.orDie), - end: () => Queue.end(outgoing).pipe(Effect.orDie), - clientIds: Effect.succeed(new Set([0])), - initialMessage: Effect.succeedNone, - supportsAck: true, - supportsTransferables: false, - supportsSpanPropagation: true, - }); + yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped); - const sendNotification = Effect.fn("sendNotification")(function* ( - method: string, - payload: unknown, - ) { - yield* offerOutgoing({ - _tag: "Request", - id: "", - tag: method, - payload, - headers: [], - }); - }); + const clientProtocol = RpcClient.Protocol.of({ + run: (f) => + Stream.fromQueue(clientQueue).pipe( + Stream.runForEach((message) => f(message)), + Effect.forever, + ), + send: (request) => offerOutgoing(request).pipe(Effect.mapError(toRpcClientError)), + supportsAck: true, + supportsTransferables: false, + }); - const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { - const requestId = yield* Ref.modify( - nextRequestId, - (current) => [current, current + 1n] as const, - ); - const deferred = yield* Deferred.make(); - yield* Ref.update(extPending, (pending) => new Map(pending).set(String(requestId), deferred)); - yield* offerOutgoing({ - _tag: "Request", - id: String(requestId), - tag: method, - payload, - headers: [], - }).pipe( - Effect.catch((error) => - removeExtPending(String(requestId)).pipe(Effect.andThen(Effect.fail(error))), - ), - ); - return yield* Deferred.await(deferred).pipe( - Effect.onInterrupt(() => removeExtPending(String(requestId))), - ); + const serverProtocol = RpcServer.Protocol.of({ + run: (f) => + Stream.fromQueue(serverQueue).pipe( + Stream.runForEach((message) => f(0, message)), + Effect.forever, + ), + disconnects, + send: (_clientId, response) => offerOutgoing(response).pipe(Effect.orDie), + end: () => Queue.end(outgoing).pipe(Effect.orDie), + clientIds: Effect.succeed(new Set([0])), + initialMessage: Effect.succeedNone, + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: true, + }); + + const sendNotification = Effect.fn("sendNotification")(function* ( + method: string, + payload: unknown, + ) { + yield* offerOutgoing({ + _tag: "Request", + id: "", + tag: method, + payload, + headers: [], }); + }); - return { - clientProtocol, - serverProtocol, - notifications: { - incoming: Stream.fromQueue(notificationQueue), - sendSessionCancel: (payload) => sendNotification("session/cancel", payload), - sendExtNotification: sendNotification, - }, - sendRequest, - } satisfies AcpPatchedProtocol; + const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { + const requestId = yield* Ref.modify( + nextRequestId, + (current) => [current, current + 1n] as const, + ); + const deferred = yield* Deferred.make(); + yield* Ref.update(extPending, (pending) => new Map(pending).set(String(requestId), deferred)); + yield* offerOutgoing({ + _tag: "Request", + id: String(requestId), + tag: method, + payload, + headers: [], + }).pipe( + Effect.catch((error) => + removeExtPending(String(requestId)).pipe(Effect.andThen(Effect.fail(error))), + ), + ); + return yield* Deferred.await(deferred).pipe( + Effect.onInterrupt(() => removeExtPending(String(requestId))), + ); }); + return { + clientProtocol, + serverProtocol, + get incoming() { + return Stream.fromQueue(notificationQueue); + }, + request: sendRequest, + notify: sendNotification, + } satisfies AcpPatchedProtocol; +}); + function isProtocolError( value: unknown, ): value is { code: number; message: string; data?: unknown } { diff --git a/packages/effect-acp/src/server.ts b/packages/effect-acp/src/server.ts deleted file mode 100644 index 36e9d394215..00000000000 --- a/packages/effect-acp/src/server.ts +++ /dev/null @@ -1,135 +0,0 @@ -import * as Effect from "effect/Effect"; - -import * as AcpSchema from "./_generated/schema.gen"; -import { CLIENT_METHODS } from "./_generated/meta.gen"; -import type * as AcpError from "./errors"; -import type * as AcpProtocol from "./protocol"; -import * as AcpTerminal from "./terminal"; - -export interface AcpServerConnection { - /** - * Sends a `session/update` notification to the client. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ - readonly sessionUpdate: ( - payload: AcpSchema.SessionNotification, - ) => Effect.Effect; - /** - * Requests client permission for an operation. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ - readonly requestPermission: ( - payload: AcpSchema.RequestPermissionRequest, - ) => Effect.Effect; - /** - * Requests structured user input from the client. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation - */ - readonly elicit: ( - payload: AcpSchema.ElicitationRequest, - ) => Effect.Effect; - /** - * Requests file contents from the client. - * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file - */ - readonly readTextFile: ( - payload: AcpSchema.ReadTextFileRequest, - ) => Effect.Effect; - /** - * Writes a text file through the client. - * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file - */ - readonly writeTextFile: ( - payload: AcpSchema.WriteTextFileRequest, - ) => Effect.Effect; - /** - * Creates a terminal on the client side. - * @see https://agentclientprotocol.com/protocol/schema#terminal/create - */ - readonly createTerminal: ( - payload: AcpSchema.CreateTerminalRequest, - ) => Effect.Effect; - /** - * Sends an ACP extension request. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly extRequest: ( - method: string, - payload: unknown, - ) => Effect.Effect; - /** - * Sends a `session/elicitation/complete` notification to the client. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete - */ - readonly elicitationComplete: ( - payload: AcpSchema.ElicitationCompleteNotification, - ) => Effect.Effect; - /** - * Sends an ACP extension notification. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly extNotification: ( - method: string, - payload: unknown, - ) => Effect.Effect; -} - -export const makeAcpServerConnection = ( - transport: AcpProtocol.AcpPatchedProtocol, -): AcpServerConnection => { - const request = (method: string, payload: unknown) => - transport.sendRequest(method, payload).pipe(Effect.map((value) => value as A)); - - return { - sessionUpdate: (payload) => - transport.notifications.sendExtNotification(CLIENT_METHODS.session_update, payload), - requestPermission: (payload) => - request( - CLIENT_METHODS.session_request_permission, - payload, - ), - elicit: (payload) => - request(CLIENT_METHODS.session_elicitation, payload), - readTextFile: (payload) => - request(CLIENT_METHODS.fs_read_text_file, payload), - writeTextFile: (payload) => - request(CLIENT_METHODS.fs_write_text_file, payload).pipe( - Effect.map((response) => response ?? {}), - ), - createTerminal: (payload) => - request(CLIENT_METHODS.terminal_create, payload).pipe( - Effect.map((response) => - AcpTerminal.makeTerminal({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - output: request(CLIENT_METHODS.terminal_output, { - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - waitForExit: request( - CLIENT_METHODS.terminal_wait_for_exit, - { - sessionId: payload.sessionId, - terminalId: response.terminalId, - }, - ), - kill: request(CLIENT_METHODS.terminal_kill, { - sessionId: payload.sessionId, - terminalId: response.terminalId, - }).pipe(Effect.map((result) => result ?? {})), - release: request(CLIENT_METHODS.terminal_release, { - sessionId: payload.sessionId, - terminalId: response.terminalId, - }).pipe(Effect.map((result) => result ?? {})), - }), - ), - ), - extRequest: transport.sendRequest, - elicitationComplete: (payload) => - transport.notifications.sendExtNotification( - CLIENT_METHODS.session_elicitation_complete, - payload, - ), - extNotification: transport.notifications.sendExtNotification, - }; -}; diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index 21b47d0bc7c..5b8ef676635 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -13,71 +13,61 @@ Effect.gen(function* () { shell: process.platform === "win32", }); const handle = yield* spawner.spawn(command); - const client = yield* AcpClient.fromChildProcess(handle, { + const acpLayer = AcpClient.layerFromChildProcessHandle(handle, { logIncoming: true, logOutgoing: true, - handlers: { - requestPermission: () => - Effect.succeed({ - outcome: { - outcome: "selected", - optionId: "allow", - }, - }), - sessionUpdate: (notification) => Effect.logInfo("session/update", notification), - }, }); - const initialized = yield* client.initialize({ - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, - clientInfo: { - name: "effect-acp-example", - version: "0.0.0", - }, - }); - yield* Effect.logInfo("initialized", initialized); + yield* Effect.gen(function* () { + const acp = yield* AcpClient.AcpConnection; - const session = yield* client.createSession({ - cwd: process.cwd(), - mcpServers: [], - }); - - yield* client.setSessionConfigOption({ - sessionId: session.sessionId, - configId: "model", - value: "gpt-5.4[reasoning=medium,context=272k,fast=false]", - }); - // yield* client.setSessionConfigOption({ - // sessionId: session.sessionId, - // configId: "reasoning_effort", - // value: "high", - // }); - // yield* client.setSessionConfigOption({ - // sessionId: session.sessionId, - // configId: "context_size", - // value: "1m", - // }); - // yield* client.setSessionConfigOption({ - // sessionId: session.sessionId, - // configId: "fast_mode", - // type: "boolean", - // value: true, - // }); + yield* acp.handleRequestPermission(() => + Effect.succeed({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }), + ); + yield* acp.handleSessionUpdate((notification) => + Effect.logInfo("session/update", notification), + ); - const result = yield* client.prompt({ - sessionId: session.sessionId, - prompt: [ - { - type: "text", - text: "Illustrate your ability to create todo lists and then execute all of them. Do not write the list to disk, illustrate your built in ability!", + const initialized = yield* acp.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, }, - ], - }); + clientInfo: { + name: "effect-acp-example", + version: "0.0.0", + }, + }); + yield* Effect.logInfo("initialized", initialized); + + const session = yield* acp.createSession({ + cwd: process.cwd(), + mcpServers: [], + }); + + yield* acp.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "model", + value: "gpt-5.4[reasoning=medium,context=272k,fast=false]", + }); + + const result = yield* acp.prompt({ + sessionId: session.sessionId, + prompt: [ + { + type: "text", + text: "Illustrate your ability to create todo lists and then execute all of them. Do not write the list to disk, illustrate your built in ability!", + }, + ], + }); - yield* Effect.logInfo("prompt result", result); - yield* client.cancel({ sessionId: session.sessionId }); + yield* Effect.logInfo("prompt result", result); + yield* acp.cancel({ sessionId: session.sessionId }); + }).pipe(Effect.provide(acpLayer)); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); From 81d1239b01f4e9296d3dd97db38f36efbaffb3d2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 18:54:53 -0700 Subject: [PATCH 38/82] Align Effect catalog with merged main Co-authored-by: codex --- .../src/provider/Layers/ClaudeAdapter.ts | 6 +--- bun.lock | 29 +++++++++---------- package.json | 15 ++++------ 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c9756e87cbc..e5b8cad1678 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -40,11 +40,7 @@ import { type UserInputQuestion, ClaudeCodeEffort, } from "@t3tools/contracts"; -import { - applyClaudePromptEffortPrefix, - resolveEffort, - trimOrNull, -} from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@t3tools/shared/model"; import { Cause, DateTime, diff --git a/bun.lock b/bun.lock index 1f3457f045b..b52828f8b64 100644 --- a/bun.lock +++ b/bun.lock @@ -185,21 +185,18 @@ }, }, }, - "patchedDependencies": { - "effect@4.0.0-beta.41": "patches/effect@4.0.0-beta.41.patch", - }, "overrides": { "vite": "^8.0.0", }, "catalog": { - "@effect/language-service": "0.75.1", - "@effect/openapi-generator": "4.0.0-beta.41", - "@effect/platform-node": "4.0.0-beta.41", - "@effect/sql-sqlite-bun": "4.0.0-beta.41", - "@effect/vitest": "4.0.0-beta.41", + "@effect/language-service": "0.84.1", + "@effect/openapi-generator": "4.0.0-beta.42", + "@effect/platform-node": "4.0.0-beta.42", + "@effect/sql-sqlite-bun": "4.0.0-beta.42", + "@effect/vitest": "4.0.0-beta.42", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", - "effect": "4.0.0-beta.41", + "effect": "4.0.0-beta.42", "tsdown": "^0.20.3", "typescript": "^5.7.3", "vitest": "^4.0.0", @@ -285,17 +282,17 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], + "@effect/language-service": ["@effect/language-service@0.84.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-YUqjJU24HeYgPV453cR2fDqkZ+zZKMuxGnmxWAPscWJ6gt6FB7JZohMCOczRTIOGPrQMcloJX7BjCaPu+RNhpw=="], - "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.41", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.41", "effect": "^4.0.0-beta.41" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-7eTTSCFjSyCMaG76fl0YiFEjfdTWaUiTS2mJ4RM0Q7jUfrYyw3rwosPqVJ8N9zWmZSnoUyD+qAD/ZK8hW970hw=="], + "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.42", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.42", "effect": "^4.0.0-beta.42" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-PE4JyYd84jJIOjHEHzEwBOo0Hm4LXLQWyz9maWTeh6Sx4/VLPegh3Mfw/6TG1qj94UrCDVHGMr1UdYg3T6q6Pw=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.41", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.41", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.41", "ioredis": "^5.7.0" } }, "sha512-lgsWfvJfxwg7xXT5rK3xcPyAFPAYXXJ0u/6yCu2suOMhdR5w2W6oA2L2pIQaqap8qR8uEVB5pHq6dqVfdRP7Nw=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.42", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.42", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }, "sha512-kbdRML2FBa4q8U8rZQcnmLKZ5zN/z1bAA7t5D1/UsBHZqJgnfRgu1CP6kaEfb1Nie6YyaWshxTktZQryjvW/Yg=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.41", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.41" } }, "sha512-YzKWz1G8YofCrQ0Thxymlk71CM3q7R1vzO2vtnb7KSHkTJrvrQB8FbZaM8mEddYOoamR25f46lidFKErmmotKg=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.42", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-PC+lxLsrwob3+nBChAPrQq32olCeyApgXBvs1NrRsoArLViNT76T/68CttuCAksCZj5e1bZ1ZibLPel3vUmx2g=="], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.41", "", { "peerDependencies": { "effect": "^4.0.0-beta.41" } }, "sha512-z3OvuTKE7GYIMZefKrTHNNuPaXxVpcSZmEAJd8ITap8wHVEEBs4WT+p/CHDae4dlJc6HJeWGSHxQuPVmdC16bA=="], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.42", "", { "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-Ah2QfkeV+I9r5OBVJijSDnFXCv51giBXngSwhju5gefc0uWiM3G1tsYAqrNX24HlvFFEnOAZqNf/Sq1h4NqOAA=="], - "@effect/vitest": ["@effect/vitest@4.0.0-beta.41", "", { "peerDependencies": { "effect": "^4.0.0-beta.41", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-VCwK2Sj1gbCaC7eoipS/pmty1plN08nNKF6z0sn9S3q+6OLCbeJ3+2qKzFQCnpdkvud45HNK3Z9kVCZew6kKhA=="], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.42", "", { "peerDependencies": { "effect": "^4.0.0-beta.42", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-/11arjUnCRhIrBRvOn/nrbg5p/FadjAPvStddZlpl1VrCxtB2s0n39cbG9uTyDdf1ZrRBG73Upo1ZDF1CTWy8w=="], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1039,7 +1036,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "effect": ["effect@4.0.0-beta.41", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-kBjbmo2qqXbOgrvZcPgVdgsOOWcGPYwRcvGO3aGPWJhpXxDFNfgtwqtU6asMq2M7LSFRx1SA+3BzJm7FDqtxew=="], + "effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="], "effect-acp": ["effect-acp@workspace:packages/effect-acp"], diff --git a/package.json b/package.json index 45f0c14b731..acc344bb938 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "scripts" ], "catalog": { - "effect": "4.0.0-beta.41", - "@effect/platform-node": "4.0.0-beta.41", - "@effect/sql-sqlite-bun": "4.0.0-beta.41", - "@effect/vitest": "4.0.0-beta.41", - "@effect/openapi-generator": "4.0.0-beta.41", - "@effect/language-service": "0.75.1", + "effect": "4.0.0-beta.42", + "@effect/platform-node": "4.0.0-beta.42", + "@effect/sql-sqlite-bun": "4.0.0-beta.42", + "@effect/vitest": "4.0.0-beta.42", + "@effect/openapi-generator": "4.0.0-beta.42", + "@effect/language-service": "0.84.1", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", "tsdown": "^0.20.3", @@ -70,8 +70,5 @@ "workerDirectory": [ "apps/web/public" ] - }, - "patchedDependencies": { - "effect@4.0.0-beta.41": "patches/effect@4.0.0-beta.41.patch" } } From b994a72400dc87fbedd46cd3dfc2041ab297b7a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 19:11:40 -0700 Subject: [PATCH 39/82] fix ubild --- apps/server/package.json | 2 +- bun.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 7e695dcc092..de304f46cb2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,7 +28,6 @@ "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", - "effect-acp": "workspace:*", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0" @@ -42,6 +41,7 @@ "@types/bun": "catalog:", "@types/node": "catalog:", "@types/ws": "^8.5.13", + "effect-acp": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/bun.lock b/bun.lock index b52828f8b64..670bf355fce 100644 --- a/bun.lock +++ b/bun.lock @@ -53,7 +53,6 @@ "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", - "effect-acp": "workspace:*", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0", @@ -67,6 +66,7 @@ "@types/bun": "catalog:", "@types/node": "catalog:", "@types/ws": "^8.5.13", + "effect-acp": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:", From 5cb6d05e80b2eabb80516747b8dee0a11fba83ec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 21:26:49 -0700 Subject: [PATCH 40/82] agent/client separation --- apps/server/scripts/acp-mock-agent.ts | 551 ++++++++---------- .../src/provider/acp/AcpSessionRuntime.ts | 75 ++- packages/effect-acp/package.json | 8 +- packages/effect-acp/src/_internal/shared.ts | 111 ++++ packages/effect-acp/src/_internal/stdio.ts | 54 ++ packages/effect-acp/src/agent.test.ts | 179 ++++++ packages/effect-acp/src/agent.ts | 521 +++++++++++++++++ packages/effect-acp/src/client.test.ts | 28 +- packages/effect-acp/src/client.ts | 521 +++++------------ packages/effect-acp/src/protocol.test.ts | 203 +++---- packages/effect-acp/src/protocol.ts | 64 +- packages/effect-acp/src/rpc.ts | 7 - .../examples/cursor-acp-client.example.ts | 20 +- .../effect-acp/test/fixtures/acp-mock-peer.ts | 258 +++----- 14 files changed, 1493 insertions(+), 1107 deletions(-) create mode 100644 packages/effect-acp/src/_internal/shared.ts create mode 100644 packages/effect-acp/src/_internal/stdio.ts create mode 100644 packages/effect-acp/src/agent.test.ts create mode 100644 packages/effect-acp/src/agent.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 52f55766519..8b0adaf704b 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1,31 +1,33 @@ #!/usr/bin/env bun -/** - * Minimal NDJSON JSON-RPC "agent" for ACP client tests. - * Reads stdin lines; writes responses/notifications to stdout. - */ -import * as readline from "node:readline"; import { appendFileSync } from "node:fs"; -import { AGENT_METHODS, CLIENT_METHODS } from "effect-acp/schema"; +import * as Effect from "effect/Effect"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; + +import * as EffectAcpAgent from "effect-acp/agent"; +import * as AcpError from "effect-acp/errors"; +import type * as AcpSchema from "effect-acp/schema"; -const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const sessionId = "mock-session-1"; + let currentModeId = "ask"; let currentModelId = "default"; -let nextRequestId = 1; +const cancelledSessions = new Set(); -function configOptions() { +function configOptions(): ReadonlyArray { return [ { id: "model", name: "Model", category: "model", - type: "select", + type: "select" as const, currentValue: currentModelId, options: [ { value: "default", name: "Auto" }, @@ -37,7 +39,7 @@ function configOptions() { ]; } -const availableModes = [ +const availableModes: ReadonlyArray = [ { id: "ask", name: "Ask", @@ -54,227 +56,113 @@ const availableModes = [ description: "Write and modify code with full tool access", }, ]; -const pendingPermissionRequests = new Map(); -const pendingAskQuestionRequests = new Map(); -const cancelledPromptRequestIds = new Set(); -function send(obj: unknown) { - process.stdout.write(`${JSON.stringify(obj)}\n`); -} - -function modeState() { +function modeState(): AcpSchema.SessionModeState { return { currentModeId, availableModes, }; } -function sendSessionUpdate(update: unknown, session = sessionId) { - send({ - jsonrpc: "2.0", - method: CLIENT_METHODS.session_update, - params: { - sessionId: session, - update, - }, - }); -} +const program = Effect.gen(function* () { + const agent = yield* EffectAcpAgent.AcpAgent; -rl.on("line", (line) => { - const trimmed = line.trim(); - if (!trimmed) return; - let msg; - try { - msg = JSON.parse(trimmed); - } catch { - return; - } - if (!msg || typeof msg !== "object") return; - if (requestLogPath) { - appendFileSync(requestLogPath, `${JSON.stringify(msg)}\n`, "utf8"); - } - - const rpcMessage = msg as { - id?: number | string; - method?: string; - params?: Record; - }; - const id = rpcMessage.id; - const method = rpcMessage.method; - - if (method === undefined && id !== undefined && pendingPermissionRequests.has(id)) { - const pending = pendingPermissionRequests.get(id); - pendingPermissionRequests.delete(id); - const outcome = (rpcMessage.params?.outcome ?? - (msg as { result?: { outcome?: unknown } }).result?.outcome) as - | { outcome?: unknown } - | undefined; - const cancelled = - cancelledPromptRequestIds.has(pending.promptRequestId) || - (typeof outcome === "object" && - outcome !== null && - "outcome" in outcome && - outcome.outcome === "cancelled"); - cancelledPromptRequestIds.delete(pending.promptRequestId); - sendSessionUpdate( - { - sessionUpdate: "tool_call_update", - toolCallId: pending.toolCallId, - title: "Terminal", - kind: "execute", - status: "completed", - rawOutput: { - exitCode: 0, - stdout: '{ "name": "t3" }', - stderr: "", + yield* agent.handleInitialize(() => + Effect.succeed({ + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + }), + ); + + yield* agent.handleAuthenticate(() => Effect.succeed({})); + + yield* agent.handleCreateSession(() => + Effect.succeed({ + sessionId, + modes: modeState(), + configOptions: configOptions(), + }), + ); + + yield* agent.handleLoadSession((request) => + agent.client + .sessionUpdate({ + sessionId: String(request.sessionId ?? sessionId), + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "replay" }, }, - }, - pending.sessionId, - ); - sendSessionUpdate( - { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "hello from mock" }, - }, - pending.sessionId, - ); - send({ - jsonrpc: "2.0", - id: pending.promptRequestId, - result: { stopReason: cancelled ? "cancelled" : "end_turn" }, - }); - return; - } - - if (method === undefined && id !== undefined && pendingAskQuestionRequests.has(id)) { - const pending = pendingAskQuestionRequests.get(id); - pendingAskQuestionRequests.delete(id); - send({ - jsonrpc: "2.0", - id: pending.promptRequestId, - result: { stopReason: "end_turn" }, - }); - return; - } - - if (method === AGENT_METHODS.initialize && id !== undefined) { - send({ - jsonrpc: "2.0", - id, - result: { - protocolVersion: 1, - agentCapabilities: { loadSession: true }, - }, - }); - return; - } - - if (method === AGENT_METHODS.authenticate && id !== undefined) { - send({ jsonrpc: "2.0", id, result: { authenticated: true } }); - return; - } - - if (method === AGENT_METHODS.session_new && id !== undefined) { - send({ - jsonrpc: "2.0", - id, - result: { - sessionId, - modes: modeState(), - configOptions: configOptions(), - }, - }); - return; - } - - if (method === AGENT_METHODS.session_load && id !== undefined) { - const requestedSessionId = rpcMessage.params?.sessionId ?? sessionId; - sendSessionUpdate( - { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: "replay" }, - }, - String(requestedSessionId), - ); - send({ - jsonrpc: "2.0", - id, - result: { - modes: modeState(), + }) + .pipe( + Effect.as({ + modes: modeState(), + configOptions: configOptions(), + }), + ), + ); + + yield* agent.handleSetSessionConfigOption((request) => + Effect.gen(function* () { + if (exitOnSetConfigOption) { + return yield* Effect.sync(() => { + process.exit(7); + }); + } + if (failSetConfigOption) { + return yield* AcpError.AcpRequestError.invalidParams( + "Mock invalid params for session/set_config_option", + { + method: "session/set_config_option", + params: request, + }, + ); + } + if (request.configId === "model" && typeof request.value === "string") { + currentModelId = request.value; + } + return { configOptions: configOptions(), - }, - }); - return; - } - - if (method === AGENT_METHODS.session_set_config_option && id !== undefined) { - if (exitOnSetConfigOption) { - process.exit(7); - } - if (failSetConfigOption) { - send({ - jsonrpc: "2.0", - id, - error: { - code: -32602, - message: "Mock invalid params for session/set_config_option", - data: { - method, - params: rpcMessage.params, + }; + }), + ); + + yield* agent.handleCancel(({ sessionId }) => + Effect.sync(() => { + cancelledSessions.add(String(sessionId)); + }), + ); + + yield* agent.handlePrompt((request) => + Effect.gen(function* () { + const requestedSessionId = String(request.sessionId ?? sessionId); + + if (emitToolCalls) { + const toolCallId = "tool-call-1"; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + command: ["cat", "server/package.json"], + }, }, - }, - }); - return; - } - const configId = rpcMessage.params?.configId; - const value = rpcMessage.params?.value; - if (configId === "model" && typeof value === "string") { - currentModelId = value; - } - send({ - jsonrpc: "2.0", - id, - result: { configOptions: configOptions() }, - }); - return; - } - - if (method === AGENT_METHODS.session_prompt && id !== undefined) { - const requestedSessionId = String(rpcMessage.params?.sessionId ?? sessionId); - if (emitToolCalls) { - const toolCallId = "tool-call-1"; - const permissionRequestId = nextRequestId++; - sendSessionUpdate( - { - sessionUpdate: "tool_call", - toolCallId, - title: "Terminal", - kind: "execute", - status: "pending", - rawInput: { - command: ["cat", "server/package.json"], + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", }, - }, - requestedSessionId, - ); - sendSessionUpdate( - { - sessionUpdate: "tool_call_update", - toolCallId, - status: "in_progress", - }, - requestedSessionId, - ); - pendingPermissionRequests.set(permissionRequestId, { - promptRequestId: id, - sessionId: requestedSessionId, - toolCallId, - }); - send({ - jsonrpc: "2.0", - id: permissionRequestId, - method: CLIENT_METHODS.session_request_permission, - params: { + }); + + const permission = yield* agent.client.requestPermission({ sessionId: requestedSessionId, toolCall: { toolCallId, @@ -296,21 +184,41 @@ rl.on("line", (line) => { { optionId: "allow-always", name: "Allow always", kind: "allow_always" }, { optionId: "reject-once", name: "Reject", kind: "reject_once" }, ], - }, - }); - return; - } - if (emitAskQuestion) { - const askQuestionRequestId = nextRequestId++; - pendingAskQuestionRequests.set(askQuestionRequestId, { - promptRequestId: id, - sessionId: requestedSessionId, - }); - send({ - jsonrpc: "2.0", - id: askQuestionRequestId, - method: "cursor/ask_question", - params: { + }); + + const cancelled = + cancelledSessions.delete(requestedSessionId) || + permission.outcome.outcome === "cancelled"; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + title: "Terminal", + kind: "execute", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: '{ "name": "t3" }', + stderr: "", + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from mock" }, + }, + }); + + return { stopReason: cancelled ? "cancelled" : "end_turn" }; + } + + if (emitAskQuestion) { + yield* agent.client.extRequest("cursor/ask_question", { toolCallId: "ask-question-tool-call-1", title: "Question", questions: [ @@ -323,83 +231,112 @@ rl.on("line", (line) => { ], }, ], + }); + + return { stopReason: "end_turn" }; + } + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Inspect mock ACP state", + priority: "high", + status: "completed", + }, + { + content: "Implement the requested change", + priority: "high", + status: "in_progress", + }, + ], }, }); - return; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from mock" }, + }, + }); + + return { stopReason: "end_turn" }; + }), + ); + + yield* agent.handleUnknownExtRequest((method, params) => { + if (method !== "session/mode/set") { + return Effect.fail(AcpError.AcpRequestError.methodNotFound(method)); } - sendSessionUpdate( - { - sessionUpdate: "plan", - explanation: `Mock plan while in ${currentModeId}`, - entries: [ - { - content: "Inspect mock ACP state", - priority: "high", - status: "completed", - }, - { - content: "Implement the requested change", - priority: "high", - status: "in_progress", - }, - ], - }, - requestedSessionId, - ); - sendSessionUpdate( - { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "hello from mock" }, - }, - requestedSessionId, - ); - send({ - jsonrpc: "2.0", - id, - result: { stopReason: "end_turn" }, - }); - return; - } - - if ( - (method === AGENT_METHODS.session_set_mode || method === "session/mode/set") && - id !== undefined - ) { + const nextModeId = - typeof rpcMessage.params?.modeId === "string" - ? rpcMessage.params.modeId - : typeof rpcMessage.params?.mode === "string" - ? rpcMessage.params.mode + typeof params === "object" && + params !== null && + "modeId" in params && + typeof params.modeId === "string" + ? params.modeId + : typeof params === "object" && + params !== null && + "mode" in params && + typeof params.mode === "string" + ? params.mode : undefined; + const requestedSessionId = + typeof params === "object" && + params !== null && + "sessionId" in params && + typeof params.sessionId === "string" + ? params.sessionId + : sessionId; + if (typeof nextModeId === "string" && nextModeId.trim()) { currentModeId = nextModeId.trim(); - sendSessionUpdate({ - sessionUpdate: "current_mode_update", - currentModeId, - }); - } - send({ jsonrpc: "2.0", id, result: {} }); - return; - } - - if (method === AGENT_METHODS.session_cancel) { - const cancelledSessionId = rpcMessage.params?.sessionId; - for (const pending of pendingPermissionRequests.values()) { - if (pending.sessionId === cancelledSessionId) { - cancelledPromptRequestIds.add(pending.promptRequestId); - } - } - if (id !== undefined) { - send({ jsonrpc: "2.0", id, result: null }); + return agent.client + .sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId, + }, + }) + .pipe(Effect.as({})); } - return; - } - - if (id !== undefined) { - send({ - jsonrpc: "2.0", - id, - error: { code: -32601, message: `Unhandled method: ${String(method)}` }, - }); - } -}); + + return Effect.succeed({}); + }); + + return yield* Effect.never; +}).pipe( + Effect.provide( + EffectAcpAgent.layerStdio( + requestLogPath + ? { + logIncoming: true, + logger: (event) => { + if (event.direction !== "incoming" || event.stage !== "raw") { + return Effect.void; + } + if (typeof event.payload !== "string") { + return Effect.void; + } + const payload = event.payload; + return Effect.sync(() => { + appendFileSync( + requestLogPath, + payload.endsWith("\n") ? payload : `${payload}\n`, + "utf8", + ); + }); + }, + } + : {}, + ), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), +); + +NodeRuntime.runMain(program); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index db5f214223f..bb4b03f4d06 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -71,21 +71,21 @@ export interface AcpSessionRuntimeStartResult { } export interface AcpSessionRuntimeShape { - readonly handleRequestPermission: EffectAcpClient.AcpConnectionShape["handleRequestPermission"]; - readonly handleElicitation: EffectAcpClient.AcpConnectionShape["handleElicitation"]; - readonly handleReadTextFile: EffectAcpClient.AcpConnectionShape["handleReadTextFile"]; - readonly handleWriteTextFile: EffectAcpClient.AcpConnectionShape["handleWriteTextFile"]; - readonly handleCreateTerminal: EffectAcpClient.AcpConnectionShape["handleCreateTerminal"]; - readonly handleTerminalOutput: EffectAcpClient.AcpConnectionShape["handleTerminalOutput"]; - readonly handleTerminalWaitForExit: EffectAcpClient.AcpConnectionShape["handleTerminalWaitForExit"]; - readonly handleTerminalKill: EffectAcpClient.AcpConnectionShape["handleTerminalKill"]; - readonly handleTerminalRelease: EffectAcpClient.AcpConnectionShape["handleTerminalRelease"]; - readonly handleSessionUpdate: EffectAcpClient.AcpConnectionShape["handleSessionUpdate"]; - readonly handleElicitationComplete: EffectAcpClient.AcpConnectionShape["handleElicitationComplete"]; - readonly handleUnknownExtRequest: EffectAcpClient.AcpConnectionShape["handleUnknownExtRequest"]; - readonly handleUnknownExtNotification: EffectAcpClient.AcpConnectionShape["handleUnknownExtNotification"]; - readonly handleExtRequest: EffectAcpClient.AcpConnectionShape["handleExtRequest"]; - readonly handleExtNotification: EffectAcpClient.AcpConnectionShape["handleExtNotification"]; + readonly handleRequestPermission: EffectAcpClient.AcpClientShape["handleRequestPermission"]; + readonly handleElicitation: EffectAcpClient.AcpClientShape["handleElicitation"]; + readonly handleReadTextFile: EffectAcpClient.AcpClientShape["handleReadTextFile"]; + readonly handleWriteTextFile: EffectAcpClient.AcpClientShape["handleWriteTextFile"]; + readonly handleCreateTerminal: EffectAcpClient.AcpClientShape["handleCreateTerminal"]; + readonly handleTerminalOutput: EffectAcpClient.AcpClientShape["handleTerminalOutput"]; + readonly handleTerminalWaitForExit: EffectAcpClient.AcpClientShape["handleTerminalWaitForExit"]; + readonly handleTerminalKill: EffectAcpClient.AcpClientShape["handleTerminalKill"]; + readonly handleTerminalRelease: EffectAcpClient.AcpClientShape["handleTerminalRelease"]; + readonly handleSessionUpdate: EffectAcpClient.AcpClientShape["handleSessionUpdate"]; + readonly handleElicitationComplete: EffectAcpClient.AcpClientShape["handleElicitationComplete"]; + readonly handleUnknownExtRequest: EffectAcpClient.AcpClientShape["handleUnknownExtRequest"]; + readonly handleUnknownExtNotification: EffectAcpClient.AcpClientShape["handleUnknownExtNotification"]; + readonly handleExtRequest: EffectAcpClient.AcpClientShape["handleExtRequest"]; + readonly handleExtNotification: EffectAcpClient.AcpClientShape["handleExtNotification"]; readonly start: () => Effect.Effect; readonly events: Stream.Stream; readonly getModeState: Effect.Effect; @@ -204,7 +204,7 @@ const makeAcpSessionRuntime = ( ); const acpContext = yield* Layer.build( - EffectAcpClient.layerFromChildProcessHandle(child, { + EffectAcpClient.layerChildProcess(child, { ...(options.protocolLogging?.logIncoming !== undefined ? { logIncoming: options.protocolLogging.logIncoming } : {}), @@ -215,9 +215,7 @@ const makeAcpSessionRuntime = ( }), ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); - const acp = yield* Effect.service(EffectAcpClient.AcpConnection).pipe( - Effect.provide(acpContext), - ); + const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); yield* acp.handleSessionUpdate((notification) => handleSessionUpdate({ @@ -320,7 +318,7 @@ const makeAcpSessionRuntime = ( return runLoggedRequest( "session/set_config_option", requestPayload, - acp.setSessionConfigOption(requestPayload), + acp.agent.setSessionConfigOption(requestPayload), ).pipe(Effect.tap((response) => updateConfigOptions(response))); }), ); @@ -338,7 +336,7 @@ const makeAcpSessionRuntime = ( const initializeResult = yield* runLoggedRequest( "initialize", initializePayload, - acp.initialize(initializePayload), + acp.agent.initialize(initializePayload), ); const authenticatePayload = { @@ -348,7 +346,7 @@ const makeAcpSessionRuntime = ( yield* runLoggedRequest( "authenticate", authenticatePayload, - acp.authenticate(authenticatePayload), + acp.agent.authenticate(authenticatePayload), ); let sessionId: string; @@ -365,7 +363,7 @@ const makeAcpSessionRuntime = ( const resumed = yield* runLoggedRequest( "session/load", loadPayload, - acp.loadSession(loadPayload), + acp.agent.loadSession(loadPayload), ).pipe(Effect.exit); if (Exit.isSuccess(resumed)) { sessionId = options.resumeSessionId; @@ -378,7 +376,7 @@ const makeAcpSessionRuntime = ( const created = yield* runLoggedRequest( "session/new", createPayload, - acp.createSession(createPayload), + acp.agent.createSession(createPayload), ); sessionId = created.sessionId; sessionSetupResult = created; @@ -391,7 +389,7 @@ const makeAcpSessionRuntime = ( const created = yield* runLoggedRequest( "session/new", createPayload, - acp.createSession(createPayload), + acp.agent.createSession(createPayload), ); sessionId = created.sessionId; sessionSetupResult = created; @@ -467,34 +465,27 @@ const makeAcpSessionRuntime = ( sessionId: started.sessionId, ...payload, } satisfies EffectAcpSchema.PromptRequest; - return runLoggedRequest("session/prompt", requestPayload, acp.prompt(requestPayload)); - }), - ), - cancel: getStartedState.pipe( - Effect.flatMap((started) => acp.cancel({ sessionId: started.sessionId })), - ), - setMode: (modeId) => - getStartedState.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId: started.sessionId, - modeId, - } satisfies EffectAcpSchema.SetSessionModeRequest; return runLoggedRequest( - "session/set_mode", + "session/prompt", requestPayload, - acp.setSessionMode(requestPayload), + acp.agent.prompt(requestPayload), ); }), ), + cancel: getStartedState.pipe( + Effect.flatMap((started) => acp.agent.cancel({ sessionId: started.sessionId })), + ), + setMode: (modeId) => + getStartedState.pipe(Effect.flatMap(() => setConfigOption("mode", modeId))), setConfigOption, setModel: (model) => getStartedState.pipe( Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), Effect.asVoid, ), - request: (method, payload) => runLoggedRequest(method, payload, acp.request(method, payload)), - notify: acp.notify, + request: (method, payload) => + runLoggedRequest(method, payload, acp.raw.request(method, payload)), + notify: acp.raw.notify, close, } satisfies AcpSessionRuntimeShape; }); diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index 4c9fb019ec6..296d64fc4fc 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -7,6 +7,10 @@ "types": "./src/client.ts", "import": "./src/client.ts" }, + "./agent": { + "types": "./src/agent.ts", + "import": "./src/agent.ts" + }, "./schema": { "types": "./src/schema.ts", "import": "./src/schema.ts" @@ -29,8 +33,8 @@ } }, "scripts": { - "dev": "tsdown src/client.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --watch --clean", - "build": "tsdown src/client.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --clean", + "dev": "tsdown src/client.ts src/agent.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --watch --clean", + "build": "tsdown src/client.ts src/agent.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --clean", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts new file mode 100644 index 00000000000..7e16aa870f2 --- /dev/null +++ b/packages/effect-acp/src/_internal/shared.ts @@ -0,0 +1,111 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import { RpcClientError } from "effect/unstable/rpc"; + +import * as AcpSchema from "../_generated/schema.gen"; +import * as AcpError from "../errors"; + +const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); + +export const callRpc = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchTag("RpcClientError", (error) => + Effect.fail( + new AcpError.AcpTransportError({ + detail: error.message, + cause: error, + }), + ), + ), + Effect.catchIf(Schema.is(AcpSchema.Error), (error) => + Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)), + ), + ); + +export const runHandler = Effect.fnUntraced(function* ( + handler: ((payload: A) => Effect.Effect) | undefined, + payload: A, + method: string, +) { + if (!handler) { + return yield* AcpError.AcpRequestError.methodNotFound(method); + } + return yield* handler(payload).pipe( + Effect.mapError((error) => + Schema.is(AcpError.AcpRequestError)(error) + ? error.toProtocolError() + : AcpError.AcpRequestError.internalError(error.message).toProtocolError(), + ), + ); +}); + +export function decodeExtRequestRegistration( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, +) { + return (params: unknown): Effect.Effect => + Schema.decodeUnknownEffect(payload)(params).pipe( + Effect.mapError((error) => + AcpError.AcpRequestError.invalidParams( + `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, + { issue: error.issue }, + ), + ), + Effect.flatMap((decoded) => handler(decoded)), + ); +} + +export function decodeExtNotificationRegistration( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, +) { + return (params: unknown): Effect.Effect => + Schema.decodeUnknownEffect(payload)(params).pipe( + Effect.mapError( + (error) => + new AcpError.AcpProtocolParseError({ + detail: `Invalid ${method} notification payload: ${formatSchemaIssue(error.issue)}`, + cause: error, + }), + ), + Effect.flatMap((decoded) => handler(decoded)), + ); +} + +const encoder = new TextEncoder(); + +const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); +const JsonRpcHeaders = Schema.Array(Schema.Unknown); + +export const jsonRpcRequest = (method: string, params: Schema.Codec) => + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: JsonRpcId, + method: Schema.Literal(method), + params, + headers: JsonRpcHeaders, + }); + +export const jsonRpcNotification = (method: string, params: Schema.Codec) => + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + method: Schema.Literal(method), + params, + }); + +export const jsonRpcResponse = (result: Schema.Codec) => + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: JsonRpcId, + result, + }); + +export const encodeJsonl = (schema: Schema.Codec, value: A) => + Effect.map(Schema.encodeEffect(Schema.fromJsonString(schema))(value), (encoded) => + encoder.encode(`${encoded}\n`), + ); diff --git a/packages/effect-acp/src/_internal/stdio.ts b/packages/effect-acp/src/_internal/stdio.ts new file mode 100644 index 00000000000..674a51ba893 --- /dev/null +++ b/packages/effect-acp/src/_internal/stdio.ts @@ -0,0 +1,54 @@ +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stdio from "effect/Stdio"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as AcpError from "../errors"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export const makeChildStdio = (handle: ChildProcessSpawner.ChildProcessHandle) => + Stdio.make({ + args: Effect.succeed([]), + stdin: handle.stdout, + stdout: () => + Sink.mapInput(handle.stdin, (chunk: string | Uint8Array) => + typeof chunk === "string" ? encoder.encode(chunk) : chunk, + ), + stderr: () => Sink.drain, + }); + +export const makeInMemoryStdio = Effect.fn("makeInMemoryStdio")(function* () { + const input = yield* Queue.unbounded>(); + const output = yield* Queue.unbounded(); + + return { + stdio: Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.fromQueue(input), + stdout: () => + Sink.forEach((chunk: string | Uint8Array) => + Queue.offer(output, typeof chunk === "string" ? chunk : decoder.decode(chunk)), + ), + stderr: () => Sink.drain, + }), + input, + output, + }; +}); + +export const makeTerminationError = ( + handle: ChildProcessSpawner.ChildProcessHandle, +): Effect.Effect => + Effect.match(handle.exitCode, { + onFailure: (cause) => + new AcpError.AcpTransportError({ + detail: "Failed to determine ACP process exit status", + cause, + }), + onSuccess: (code) => new AcpError.AcpProcessExitedError({ code }), + }); diff --git a/packages/effect-acp/src/agent.test.ts b/packages/effect-acp/src/agent.test.ts new file mode 100644 index 00000000000..15cba74725d --- /dev/null +++ b/packages/effect-acp/src/agent.test.ts @@ -0,0 +1,179 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import { assert, it } from "@effect/vitest"; + +import * as AcpAgent from "./agent"; +import * as AcpSchema from "./_generated/schema.gen"; +import { + encodeJsonl, + jsonRpcNotification, + jsonRpcRequest, + jsonRpcResponse, +} from "./_internal/shared"; +import { makeInMemoryStdio } from "./_internal/stdio"; + +const RequestPermissionRequest = jsonRpcRequest( + "session/request_permission", + AcpSchema.RequestPermissionRequest, +); +const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest); +const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse); +const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); +const SessionCancelNotification = jsonRpcNotification( + "session/cancel", + AcpSchema.CancelNotification, +); +const ExtPingNotification = jsonRpcNotification("x/ping", Schema.Struct({ count: Schema.Number })); + +it.effect("effect-acp agent handles core agent requests and outbound client requests", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const cancelNotifications = yield* Ref.make>([]); + const extNotifications = yield* Ref.make>([]); + const cancelReceived = yield* Deferred.make(); + const extReceived = yield* Deferred.make(); + const scope = yield* Scope.make(); + const context = yield* Layer.buildWithScope(AcpAgent.layer(stdio), scope); + + yield* Effect.gen(function* () { + const agent = yield* AcpAgent.AcpAgent; + + yield* agent.handleInitialize(() => + Effect.succeed({ + protocolVersion: 1, + agentCapabilities: {}, + agentInfo: { + name: "mock-agent", + version: "0.0.0", + }, + }), + ); + yield* agent.handleCancel((notification) => + Ref.update(cancelNotifications, (current) => [...current, notification.sessionId]).pipe( + Effect.andThen(Deferred.succeed(cancelReceived, undefined)), + ), + ); + yield* agent.handleExtNotification( + "x/ping", + Schema.Struct({ count: Schema.Number }), + (payload) => + Ref.update(extNotifications, (current) => [...current, payload.count]).pipe( + Effect.andThen(Deferred.succeed(extReceived, undefined)), + ), + ); + + const permissionFiber = yield* agent.client + .requestPermission({ + sessionId: "session-1", + toolCall: { + toolCallId: "tool-1", + title: "Allow mock action", + }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }) + .pipe(Effect.forkScoped); + + const permissionRequest = yield* Schema.decodeEffect( + Schema.fromJsonString(RequestPermissionRequest), + )(yield* Queue.take(output)); + assert.equal(permissionRequest.jsonrpc, "2.0"); + assert.equal(permissionRequest.id, 1); + assert.equal(permissionRequest.method, "session/request_permission"); + assert.deepEqual(permissionRequest.params, { + sessionId: "session-1", + toolCall: { + toolCallId: "tool-1", + title: "Allow mock action", + }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }); + assert.deepEqual(permissionRequest.headers, []); + + yield* Queue.offer( + input, + yield* encodeJsonl(RequestPermissionResponse, { + jsonrpc: "2.0", + id: 1, + result: { + outcome: { + outcome: "selected", + optionId: "allow", + }, + }, + }), + ); + + const permission = yield* Fiber.join(permissionFiber); + assert.equal(permission.outcome.outcome, "selected"); + + yield* Queue.offer( + input, + yield* encodeJsonl(InitializeRequest, { + jsonrpc: "2.0", + id: 2, + method: "initialize", + params: { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: "effect-acp-test", + version: "0.0.0", + }, + }, + headers: [], + }), + ); + + const initResponse = yield* Schema.decodeEffect(Schema.fromJsonString(InitializeResponse))( + yield* Queue.take(output), + ); + assert.deepEqual(initResponse, { + jsonrpc: "2.0", + id: 2, + result: { + protocolVersion: 1, + agentCapabilities: {}, + agentInfo: { + name: "mock-agent", + version: "0.0.0", + }, + }, + }); + + yield* Queue.offer( + input, + yield* encodeJsonl(SessionCancelNotification, { + jsonrpc: "2.0", + method: "session/cancel", + params: { + sessionId: "session-1", + }, + }), + ); + yield* Queue.offer( + input, + yield* encodeJsonl(ExtPingNotification, { + jsonrpc: "2.0", + method: "x/ping", + params: { count: 2 }, + }), + ); + + yield* Deferred.await(cancelReceived); + yield* Deferred.await(extReceived); + assert.deepEqual(yield* Ref.get(cancelNotifications), ["session-1"]); + assert.deepEqual(yield* Ref.get(extNotifications), [2]); + }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); + }), +); diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts new file mode 100644 index 00000000000..fba929c08c6 --- /dev/null +++ b/packages/effect-acp/src/agent.ts @@ -0,0 +1,521 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as ServiceMap from "effect/ServiceMap"; +import * as Stream from "effect/Stream"; +import * as Stdio from "effect/Stdio"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcServer from "effect/unstable/rpc/RpcServer"; + +import * as AcpSchema from "./_generated/schema.gen"; +import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen"; +import * as AcpError from "./errors"; +import * as AcpProtocol from "./protocol"; +import * as AcpRpcs from "./rpc"; +import { + callRpc, + decodeExtNotificationRegistration, + decodeExtRequestRegistration, + runHandler, +} from "./_internal/shared"; +import * as AcpTerminal from "./terminal"; + +export interface AcpAgentOptions { + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; +} + +export interface AcpAgentShape { + readonly raw: { + /** + * Stream of inbound ACP notifications observed on the connection. + */ + readonly notifications: Stream.Stream; + /** + * Sends a generic ACP extension request. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly request: ( + method: string, + payload: unknown, + ) => Effect.Effect; + /** + * Sends a generic ACP extension notification. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly notify: (method: string, payload: unknown) => Effect.Effect; + }; + readonly client: { + /** + * Requests client permission for an operation. + * @see https://agentclientprotocol.com/protocol/schema#session/request_permission + */ + readonly requestPermission: ( + payload: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect; + /** + * Requests structured user input from the client. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation + */ + readonly elicit: ( + payload: AcpSchema.ElicitationRequest, + ) => Effect.Effect; + /** + * Requests file contents from the client. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file + */ + readonly readTextFile: ( + payload: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect; + /** + * Writes a text file through the client. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + */ + readonly writeTextFile: ( + payload: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect; + /** + * Creates a terminal on the client side. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create + */ + readonly createTerminal: ( + payload: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect; + /** + * Sends a `session/update` notification to the client. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly sessionUpdate: ( + payload: AcpSchema.SessionNotification, + ) => Effect.Effect; + /** + * Sends a `session/elicitation/complete` notification to the client. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete + */ + readonly elicitationComplete: ( + payload: AcpSchema.ElicitationCompleteNotification, + ) => Effect.Effect; + /** + * Sends an ACP extension request to the client. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extRequest: ( + method: string, + payload: unknown, + ) => Effect.Effect; + /** + * Sends an ACP extension notification to the client. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly extNotification: ( + method: string, + payload: unknown, + ) => Effect.Effect; + }; + /** + * Registers a handler for `initialize`. + * @see https://agentclientprotocol.com/protocol/schema#initialize + */ + readonly handleInitialize: ( + handler: ( + request: AcpSchema.InitializeRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `authenticate`. + * @see https://agentclientprotocol.com/protocol/schema#authenticate + */ + readonly handleAuthenticate: ( + handler: ( + request: AcpSchema.AuthenticateRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleLogout: ( + handler: ( + request: AcpSchema.LogoutRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleCreateSession: ( + handler: ( + request: AcpSchema.NewSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleLoadSession: ( + handler: ( + request: AcpSchema.LoadSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleListSessions: ( + handler: ( + request: AcpSchema.ListSessionsRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleForkSession: ( + handler: ( + request: AcpSchema.ForkSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleResumeSession: ( + handler: ( + request: AcpSchema.ResumeSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleCloseSession: ( + handler: ( + request: AcpSchema.CloseSessionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleSetSessionModel: ( + handler: ( + request: AcpSchema.SetSessionModelRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handleSetSessionConfigOption: ( + handler: ( + request: AcpSchema.SetSessionConfigOptionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + readonly handlePrompt: ( + handler: ( + request: AcpSchema.PromptRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `session/cancel`. + * @see https://agentclientprotocol.com/protocol/schema#session/cancel + */ + readonly handleCancel: ( + handler: (notification: AcpSchema.CancelNotification) => Effect.Effect, + ) => Effect.Effect; + readonly handleUnknownExtRequest: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; + readonly handleUnknownExtNotification: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; + readonly handleExtRequest: ( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; + readonly handleExtNotification: ( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; +} + +export class AcpAgent extends ServiceMap.Service()( + "effect-acp/AcpAgent", +) {} + +interface AcpCoreAgentRequestHandlers { + initialize?: ( + request: AcpSchema.InitializeRequest, + ) => Effect.Effect; + authenticate?: ( + request: AcpSchema.AuthenticateRequest, + ) => Effect.Effect; + logout?: ( + request: AcpSchema.LogoutRequest, + ) => Effect.Effect; + createSession?: ( + request: AcpSchema.NewSessionRequest, + ) => Effect.Effect; + loadSession?: ( + request: AcpSchema.LoadSessionRequest, + ) => Effect.Effect; + listSessions?: ( + request: AcpSchema.ListSessionsRequest, + ) => Effect.Effect; + forkSession?: ( + request: AcpSchema.ForkSessionRequest, + ) => Effect.Effect; + resumeSession?: ( + request: AcpSchema.ResumeSessionRequest, + ) => Effect.Effect; + closeSession?: ( + request: AcpSchema.CloseSessionRequest, + ) => Effect.Effect; + setSessionModel?: ( + request: AcpSchema.SetSessionModelRequest, + ) => Effect.Effect; + setSessionConfigOption?: ( + request: AcpSchema.SetSessionConfigOptionRequest, + ) => Effect.Effect; + prompt?: ( + request: AcpSchema.PromptRequest, + ) => Effect.Effect; +} + +const decodeCancelNotification = Schema.decodeUnknownEffect(AcpSchema.CancelNotification); + +export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( + stdio: Stdio.Stdio, + options: AcpAgentOptions = {}, +): Effect.fn.Return { + const coreHandlers: AcpCoreAgentRequestHandlers = {}; + const cancelHandlers: Array< + (notification: AcpSchema.CancelNotification) => Effect.Effect + > = []; + const extRequestHandlers = new Map< + string, + (params: unknown) => Effect.Effect + >(); + const extNotificationHandlers = new Map< + string, + (params: unknown) => Effect.Effect + >(); + let unknownExtRequestHandler: + | ((method: string, params: unknown) => Effect.Effect) + | undefined; + let unknownExtNotificationHandler: + | ((method: string, params: unknown) => Effect.Effect) + | undefined; + + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(AcpRpcs.AgentRpcs.requests.keys()), + ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}), + ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}), + ...(options.logger ? { logger: options.logger } : {}), + onNotification: (notification) => { + if ( + notification._tag === "ExtNotification" && + notification.method === AGENT_METHODS.session_cancel + ) { + return decodeCancelNotification(notification.params).pipe( + Effect.mapError( + (error) => + new AcpError.AcpProtocolParseError({ + detail: `Invalid ${AGENT_METHODS.session_cancel} notification payload`, + cause: error, + }), + ), + Effect.flatMap((decoded) => + Effect.forEach(cancelHandlers, (handler) => handler(decoded), { discard: true }), + ), + ); + } + + if (notification._tag !== "ExtNotification") { + return Effect.void; + } + + const handler = extNotificationHandlers.get(notification.method); + if (handler) { + return handler(notification.params); + } + return unknownExtNotificationHandler + ? unknownExtNotificationHandler(notification.method, notification.params) + : Effect.void; + }, + onExtRequest: (method, params) => { + const handler = extRequestHandlers.get(method); + if (handler) { + return handler(params); + } + return unknownExtRequestHandler + ? unknownExtRequestHandler(method, params) + : Effect.fail(AcpError.AcpRequestError.methodNotFound(method)); + }, + }); + + const agentHandlerLayer = AcpRpcs.AgentRpcs.toLayer( + AcpRpcs.AgentRpcs.of({ + [AGENT_METHODS.initialize]: (payload) => + runHandler(coreHandlers.initialize, payload, AGENT_METHODS.initialize), + [AGENT_METHODS.authenticate]: (payload) => + runHandler(coreHandlers.authenticate, payload, AGENT_METHODS.authenticate), + [AGENT_METHODS.logout]: (payload) => + runHandler(coreHandlers.logout, payload, AGENT_METHODS.logout), + [AGENT_METHODS.session_new]: (payload) => + runHandler(coreHandlers.createSession, payload, AGENT_METHODS.session_new), + [AGENT_METHODS.session_load]: (payload) => + runHandler(coreHandlers.loadSession, payload, AGENT_METHODS.session_load), + [AGENT_METHODS.session_list]: (payload) => + runHandler(coreHandlers.listSessions, payload, AGENT_METHODS.session_list), + [AGENT_METHODS.session_fork]: (payload) => + runHandler(coreHandlers.forkSession, payload, AGENT_METHODS.session_fork), + [AGENT_METHODS.session_resume]: (payload) => + runHandler(coreHandlers.resumeSession, payload, AGENT_METHODS.session_resume), + [AGENT_METHODS.session_close]: (payload) => + runHandler(coreHandlers.closeSession, payload, AGENT_METHODS.session_close), + [AGENT_METHODS.session_set_model]: (payload) => + runHandler(coreHandlers.setSessionModel, payload, AGENT_METHODS.session_set_model), + [AGENT_METHODS.session_set_config_option]: (payload) => + runHandler( + coreHandlers.setSessionConfigOption, + payload, + AGENT_METHODS.session_set_config_option, + ), + [AGENT_METHODS.session_prompt]: (payload) => + runHandler(coreHandlers.prompt, payload, AGENT_METHODS.session_prompt), + }), + ); + + yield* RpcServer.make(AcpRpcs.AgentRpcs).pipe( + Effect.provideService(RpcServer.Protocol, transport.serverProtocol), + Effect.provide(agentHandlerLayer), + Effect.forkScoped, + ); + + let nextRpcRequestId = 1n; + const rpc = yield* RpcClient.make(AcpRpcs.ClientRpcs, { + generateRequestId: () => nextRpcRequestId++ as never, + }).pipe(Effect.provideService(RpcClient.Protocol, transport.clientProtocol)); + + return AcpAgent.of({ + raw: { + notifications: transport.incoming, + request: transport.request, + notify: transport.notify, + }, + client: { + requestPermission: (payload) => + callRpc(rpc[CLIENT_METHODS.session_request_permission](payload)), + elicit: (payload) => callRpc(rpc[CLIENT_METHODS.session_elicitation](payload)), + readTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_read_text_file](payload)), + writeTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_write_text_file](payload)), + createTerminal: (payload) => + callRpc(rpc[CLIENT_METHODS.terminal_create](payload)).pipe( + Effect.map((response) => + AcpTerminal.makeTerminal({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + output: callRpc( + rpc[CLIENT_METHODS.terminal_output]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + waitForExit: callRpc( + rpc[CLIENT_METHODS.terminal_wait_for_exit]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + kill: callRpc( + rpc[CLIENT_METHODS.terminal_kill]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + release: callRpc( + rpc[CLIENT_METHODS.terminal_release]({ + sessionId: payload.sessionId, + terminalId: response.terminalId, + }), + ), + }), + ), + ), + sessionUpdate: (payload) => transport.notify(CLIENT_METHODS.session_update, payload), + elicitationComplete: (payload) => + transport.notify(CLIENT_METHODS.session_elicitation_complete, payload), + extRequest: transport.request, + extNotification: transport.notify, + }, + handleInitialize: (handler) => + Effect.suspend(() => { + coreHandlers.initialize = handler; + return Effect.void; + }), + handleAuthenticate: (handler) => + Effect.suspend(() => { + coreHandlers.authenticate = handler; + return Effect.void; + }), + handleLogout: (handler) => + Effect.suspend(() => { + coreHandlers.logout = handler; + return Effect.void; + }), + handleCreateSession: (handler) => + Effect.suspend(() => { + coreHandlers.createSession = handler; + return Effect.void; + }), + handleLoadSession: (handler) => + Effect.suspend(() => { + coreHandlers.loadSession = handler; + return Effect.void; + }), + handleListSessions: (handler) => + Effect.suspend(() => { + coreHandlers.listSessions = handler; + return Effect.void; + }), + handleForkSession: (handler) => + Effect.suspend(() => { + coreHandlers.forkSession = handler; + return Effect.void; + }), + handleResumeSession: (handler) => + Effect.suspend(() => { + coreHandlers.resumeSession = handler; + return Effect.void; + }), + handleCloseSession: (handler) => + Effect.suspend(() => { + coreHandlers.closeSession = handler; + return Effect.void; + }), + handleSetSessionModel: (handler) => + Effect.suspend(() => { + coreHandlers.setSessionModel = handler; + return Effect.void; + }), + handleSetSessionConfigOption: (handler) => + Effect.suspend(() => { + coreHandlers.setSessionConfigOption = handler; + return Effect.void; + }), + handlePrompt: (handler) => + Effect.suspend(() => { + coreHandlers.prompt = handler; + return Effect.void; + }), + handleCancel: (handler) => + Effect.suspend(() => { + cancelHandlers.push(handler); + return Effect.void; + }), + handleUnknownExtRequest: (handler) => + Effect.suspend(() => { + unknownExtRequestHandler = handler; + return Effect.void; + }), + handleUnknownExtNotification: (handler) => + Effect.suspend(() => { + unknownExtNotificationHandler = handler; + return Effect.void; + }), + handleExtRequest: (method, payload, handler) => + Effect.suspend(() => { + extRequestHandlers.set(method, decodeExtRequestRegistration(method, payload, handler)); + return Effect.void; + }), + handleExtNotification: (method, payload, handler) => + Effect.suspend(() => { + extNotificationHandlers.set( + method, + decodeExtNotificationRegistration(method, payload, handler), + ); + return Effect.void; + }), + }); +}); + +export const layer = (stdio: Stdio.Stdio, options: AcpAgentOptions = {}): Layer.Layer => + Layer.effect(AcpAgent, make(stdio, options)); + +export const layerStdio = ( + options: AcpAgentOptions = {}, +): Layer.Layer => + Layer.effect( + AcpAgent, + Effect.flatMap(Effect.service(Stdio.Stdio), (stdio) => make(stdio, options)), + ); diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 9d372bcaa34..854fdba5ac5 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -39,11 +39,11 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { const typedNotifications = yield* Ref.make>([]); const handle = yield* makeHandle(); const scope = yield* Scope.make(); - const acpLayer = AcpClient.layerFromChildProcessHandle(handle); + const acpLayer = AcpClient.layerChildProcess(handle); const context = yield* Layer.buildWithScope(acpLayer, scope); const ext = yield* Effect.gen(function* () { - const acp = yield* AcpClient.AcpConnection; + const acp = yield* AcpClient.AcpClient; yield* acp.handleRequestPermission(() => Effect.succeed({ @@ -86,7 +86,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { (payload) => Ref.update(typedNotifications, (current) => [...current, payload]), ); - const init = yield* acp.initialize({ + const init = yield* acp.agent.initialize({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, @@ -99,21 +99,21 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }); assert.equal(init.protocolVersion, 1); - yield* acp.authenticate({ methodId: "cursor_login" }); + yield* acp.agent.authenticate({ methodId: "cursor_login" }); - const session = yield* acp.createSession({ + const session = yield* acp.agent.createSession({ cwd: process.cwd(), mcpServers: [], }); assert.equal(session.sessionId, "mock-session-1"); - const prompt = yield* acp.prompt({ + const prompt = yield* acp.agent.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }], }); assert.equal(prompt.stopReason, "end_turn"); - const streamed = yield* Stream.runCollect(Stream.take(acp.notifications, 2)); + const streamed = yield* Stream.runCollect(Stream.take(acp.raw.notifications, 2)); assert.equal(streamed.length, 2); assert.equal(streamed[0]?._tag, "SessionUpdate"); assert.equal(streamed[1]?._tag, "ElicitationComplete"); @@ -122,7 +122,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { assert.deepEqual(yield* Ref.get(typedRequests), [{ message: "hello from typed request" }]); assert.deepEqual(yield* Ref.get(typedNotifications), [{ count: 2 }]); - return yield* acp.request("x/echo", { + return yield* acp.raw.request("x/echo", { hello: "world", }); }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); @@ -142,11 +142,11 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { Effect.gen(function* () { const handle = yield* makeHandle({ ACP_MOCK_BAD_TYPED_REQUEST: "1" }); const scope = yield* Scope.make(); - const acpLayer = AcpClient.layerFromChildProcessHandle(handle); + const acpLayer = AcpClient.layerChildProcess(handle); const context = yield* Layer.buildWithScope(acpLayer, scope); const result = yield* Effect.gen(function* () { - const acp = yield* AcpClient.AcpConnection; + const acp = yield* AcpClient.AcpClient; yield* acp.handleRequestPermission(() => Effect.succeed({ @@ -172,7 +172,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { () => Effect.succeed({ ok: true }), ); - yield* acp.initialize({ + yield* acp.agent.initialize({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, @@ -184,15 +184,15 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }, }); - yield* acp.authenticate({ methodId: "cursor_login" }); + yield* acp.agent.authenticate({ methodId: "cursor_login" }); - const session = yield* acp.createSession({ + const session = yield* acp.agent.createSession({ cwd: process.cwd(), mcpServers: [], }); return yield* Effect.exit( - acp.prompt({ + acp.agent.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }], }), diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 1a895c80e17..508b5e7d2ee 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -1,37 +1,134 @@ import * as Effect from "effect/Effect"; +import * as Stdio from "effect/Stdio"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import * as Scope from "effect/Scope"; import * as ServiceMap from "effect/ServiceMap"; -import * as Sink from "effect/Sink"; -import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; import * as RpcServer from "effect/unstable/rpc/RpcServer"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { RpcClientError } from "effect/unstable/rpc"; import * as AcpError from "./errors"; import * as AcpProtocol from "./protocol"; import * as AcpRpcs from "./rpc"; import * as AcpSchema from "./_generated/schema.gen"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen"; -import * as AcpTerminal from "./terminal"; +import { + callRpc, + decodeExtNotificationRegistration, + decodeExtRequestRegistration, + runHandler, +} from "./_internal/shared"; +import { makeChildStdio, makeTerminationError } from "./_internal/stdio"; -export interface AcpConnectionOptions { +export interface AcpClientOptions { readonly logIncoming?: boolean; readonly logOutgoing?: boolean; readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; } -export interface AcpConnectionShape { - readonly process: ChildProcessSpawner.ChildProcessHandle; - /** - * Stream of inbound ACP notifications observed on the connection. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ +type AcpClientRaw = { readonly notifications: Stream.Stream; + readonly request: (method: string, payload: unknown) => Effect.Effect; + readonly notify: (method: string, payload: unknown) => Effect.Effect; +}; + +export interface AcpClientShape { + readonly raw: AcpClientRaw; + readonly agent: { + /** + * Initializes the ACP session and negotiates capabilities. + * @see https://agentclientprotocol.com/protocol/schema#initialize + */ + readonly initialize: ( + payload: AcpSchema.InitializeRequest, + ) => Effect.Effect; + /** + * Performs ACP authentication when the agent requires it. + * @see https://agentclientprotocol.com/protocol/schema#authenticate + */ + readonly authenticate: ( + payload: AcpSchema.AuthenticateRequest, + ) => Effect.Effect; + /** + * Logs out the current ACP identity. + * @see https://agentclientprotocol.com/protocol/schema#logout + */ + readonly logout: ( + payload: AcpSchema.LogoutRequest, + ) => Effect.Effect; + /** + * Starts a new ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/new + */ + readonly createSession: ( + payload: AcpSchema.NewSessionRequest, + ) => Effect.Effect; + /** + * Loads a previously saved ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/load + */ + readonly loadSession: ( + payload: AcpSchema.LoadSessionRequest, + ) => Effect.Effect; + /** + * Lists available ACP sessions. + * @see https://agentclientprotocol.com/protocol/schema#session/list + */ + readonly listSessions: ( + payload: AcpSchema.ListSessionsRequest, + ) => Effect.Effect; + /** + * Forks an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/fork + */ + readonly forkSession: ( + payload: AcpSchema.ForkSessionRequest, + ) => Effect.Effect; + /** + * Resumes an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/resume + */ + readonly resumeSession: ( + payload: AcpSchema.ResumeSessionRequest, + ) => Effect.Effect; + /** + * Closes an ACP session. + * @see https://agentclientprotocol.com/protocol/schema#session/close + */ + readonly closeSession: ( + payload: AcpSchema.CloseSessionRequest, + ) => Effect.Effect; + /** + * Selects the active model for a session. + * @see https://agentclientprotocol.com/protocol/schema#session/set_model + */ + readonly setSessionModel: ( + payload: AcpSchema.SetSessionModelRequest, + ) => Effect.Effect; + /** + * Updates a session configuration option. + * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option + */ + readonly setSessionConfigOption: ( + payload: AcpSchema.SetSessionConfigOptionRequest, + ) => Effect.Effect; + /** + * Sends a prompt turn to the agent. + * @see https://agentclientprotocol.com/protocol/schema#session/prompt + */ + readonly prompt: ( + payload: AcpSchema.PromptRequest, + ) => Effect.Effect; + /** + * Sends a real ACP `session/cancel` notification. + * @see https://agentclientprotocol.com/protocol/schema#session/cancel + */ + readonly cancel: ( + payload: AcpSchema.CancelNotification, + ) => Effect.Effect; + }; /** * Registers a handler for `session/request_permission`. * @see https://agentclientprotocol.com/protocol/schema#session/request_permission @@ -163,230 +260,37 @@ export interface AcpConnectionShape { payload: Schema.Codec, handler: (payload: A) => Effect.Effect, ) => Effect.Effect; - /** - * Initializes the ACP session and negotiates capabilities. - * @see https://agentclientprotocol.com/protocol/schema#initialize - */ - readonly initialize: ( - payload: AcpSchema.InitializeRequest, - ) => Effect.Effect; - /** - * Performs ACP authentication when the agent requires it. - * @see https://agentclientprotocol.com/protocol/schema#authenticate - */ - readonly authenticate: ( - payload: AcpSchema.AuthenticateRequest, - ) => Effect.Effect; - /** - * Logs out the current ACP identity. - * @see https://agentclientprotocol.com/protocol/schema#logout - */ - readonly logout: ( - payload: AcpSchema.LogoutRequest, - ) => Effect.Effect; - /** - * Starts a new ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/new - */ - readonly createSession: ( - payload: AcpSchema.NewSessionRequest, - ) => Effect.Effect; - /** - * Loads a previously saved ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/load - */ - readonly loadSession: ( - payload: AcpSchema.LoadSessionRequest, - ) => Effect.Effect; - /** - * Lists available ACP sessions. - * @see https://agentclientprotocol.com/protocol/schema#session/list - */ - readonly listSessions: ( - payload: AcpSchema.ListSessionsRequest, - ) => Effect.Effect; - /** - * Forks an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/fork - */ - readonly forkSession: ( - payload: AcpSchema.ForkSessionRequest, - ) => Effect.Effect; - /** - * Resumes an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/resume - */ - readonly resumeSession: ( - payload: AcpSchema.ResumeSessionRequest, - ) => Effect.Effect; - /** - * Closes an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/close - */ - readonly closeSession: ( - payload: AcpSchema.CloseSessionRequest, - ) => Effect.Effect; - /** - * Changes the current session mode. - * @see https://agentclientprotocol.com/protocol/schema#session/set_mode - */ - readonly setSessionMode: ( - payload: AcpSchema.SetSessionModeRequest, - ) => Effect.Effect; - /** - * Selects the active model for a session. - * @see https://agentclientprotocol.com/protocol/schema#session/set_model - */ - readonly setSessionModel: ( - payload: AcpSchema.SetSessionModelRequest, - ) => Effect.Effect; - /** - * Updates a session configuration option. - * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option - */ - readonly setSessionConfigOption: ( - payload: AcpSchema.SetSessionConfigOptionRequest, - ) => Effect.Effect; - /** - * Sends a prompt turn to the agent. - * @see https://agentclientprotocol.com/protocol/schema#session/prompt - */ - readonly prompt: ( - payload: AcpSchema.PromptRequest, - ) => Effect.Effect; - /** - * Sends a real ACP `session/cancel` notification. - * @see https://agentclientprotocol.com/protocol/schema#session/cancel - */ - readonly cancel: ( - payload: AcpSchema.CancelNotification, - ) => Effect.Effect; - /** - * Sends an ACP extension request. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly request: (method: string, payload: unknown) => Effect.Effect; - /** - * Sends an ACP extension notification. - * @see https://agentclientprotocol.com/protocol/extensibility - */ - readonly notify: (method: string, payload: unknown) => Effect.Effect; - /** - * Requests client permission for an operation. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ - readonly requestPermission: ( - payload: AcpSchema.RequestPermissionRequest, - ) => Effect.Effect; - /** - * Requests structured user input from the client. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation - */ - readonly elicit: ( - payload: AcpSchema.ElicitationRequest, - ) => Effect.Effect; - /** - * Requests file contents from the client. - * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file - */ - readonly readTextFile: ( - payload: AcpSchema.ReadTextFileRequest, - ) => Effect.Effect; - /** - * Writes a text file through the client. - * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file - */ - readonly writeTextFile: ( - payload: AcpSchema.WriteTextFileRequest, - ) => Effect.Effect; - /** - * Creates a terminal on the client side. - * @see https://agentclientprotocol.com/protocol/schema#terminal/create - */ - readonly createTerminal: ( - payload: AcpSchema.CreateTerminalRequest, - ) => Effect.Effect; - /** - * Sends a `session/update` notification to the client. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ - readonly sessionUpdate: ( - payload: AcpSchema.SessionNotification, - ) => Effect.Effect; - /** - * Sends a `session/elicitation/complete` notification to the client. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete - */ - readonly elicitationComplete: ( - payload: AcpSchema.ElicitationCompleteNotification, - ) => Effect.Effect; } -export class AcpConnection extends ServiceMap.Service()( - "effect-acp/AcpConnection", +export class AcpClient extends ServiceMap.Service()( + "effect-acp/AcpClient", ) {} interface AcpCoreRequestHandlers { - /** - * Handles `session/request_permission`. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ requestPermission?: ( request: AcpSchema.RequestPermissionRequest, ) => Effect.Effect; - /** - * Handles `session/elicitation`. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation - */ elicitation?: ( request: AcpSchema.ElicitationRequest, ) => Effect.Effect; - /** - * Handles `fs/read_text_file`. - * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file - */ readTextFile?: ( request: AcpSchema.ReadTextFileRequest, ) => Effect.Effect; - /** - * Handles `fs/write_text_file`. - * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file - */ writeTextFile?: ( request: AcpSchema.WriteTextFileRequest, ) => Effect.Effect; - /** - * Handles `terminal/create`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/create - */ createTerminal?: ( request: AcpSchema.CreateTerminalRequest, ) => Effect.Effect; - /** - * Handles `terminal/output`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/output - */ terminalOutput?: ( request: AcpSchema.TerminalOutputRequest, ) => Effect.Effect; - /** - * Handles `terminal/wait_for_exit`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit - */ terminalWaitForExit?: ( request: AcpSchema.WaitForTerminalExitRequest, ) => Effect.Effect; - /** - * Handles `terminal/kill`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/kill - */ terminalKill?: ( request: AcpSchema.KillTerminalRequest, ) => Effect.Effect; - /** - * Handles `terminal/release`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/release - */ terminalRelease?: ( request: AcpSchema.ReleaseTerminalRequest, ) => Effect.Effect; @@ -403,13 +307,11 @@ interface AcpNotificationHandlers { >; } -const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); -const textEncoder = new TextEncoder(); - -export const makeFromChildProcessHandle = Effect.fn("makeFromChildProcessHandle")(function* ( - handle: ChildProcessSpawner.ChildProcessHandle, - options: AcpConnectionOptions = {}, -): Effect.fn.Return { +export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( + stdio: Stdio.Stdio, + options: AcpClientOptions = {}, + terminationError?: Effect.Effect, +): Effect.fn.Return { const coreHandlers: AcpCoreRequestHandlers = {}; const notificationHandlers: AcpNotificationHandlers = { sessionUpdate: [], @@ -467,7 +369,8 @@ export const makeFromChildProcessHandle = Effect.fn("makeFromChildProcessHandle" }; const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ - stdio: makeStdioFromChildProcess(handle), + stdio: stdio, + ...(terminationError ? { terminationError } : {}), serverRequestMethods: new Set(AcpRpcs.ClientRpcs.requests.keys()), ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}), ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}), @@ -519,31 +422,33 @@ export const makeFromChildProcessHandle = Effect.fn("makeFromChildProcessHandle" Effect.forkScoped, ); - const rpc = yield* RpcClient.make(AcpRpcs.AgentRpcs).pipe( - Effect.provideService(RpcClient.Protocol, transport.clientProtocol), - ); - - const callRpc = (effect: Effect.Effect) => - effect.pipe( - Effect.catchTag("RpcClientError", (error) => - Effect.fail( - new AcpError.AcpTransportError({ - detail: error.message, - cause: error, - }), - ), - ), - Effect.catchIf(Schema.is(AcpSchema.Error), (error) => - Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)), - ), - ); - - const request = (method: string, payload: unknown) => - transport.request(method, payload).pipe(Effect.map((value) => value as A)); + let nextRpcRequestId = 1n; + const rpc = yield* RpcClient.make(AcpRpcs.AgentRpcs, { + generateRequestId: () => nextRpcRequestId++ as never, + }).pipe(Effect.provideService(RpcClient.Protocol, transport.clientProtocol)); - return AcpConnection.of({ - process: handle, - notifications: transport.incoming, + return AcpClient.of({ + raw: { + notifications: transport.incoming, + request: transport.request, + notify: transport.notify, + }, + agent: { + initialize: (payload) => callRpc(rpc[AGENT_METHODS.initialize](payload)), + authenticate: (payload) => callRpc(rpc[AGENT_METHODS.authenticate](payload)), + logout: (payload) => callRpc(rpc[AGENT_METHODS.logout](payload)), + createSession: (payload) => callRpc(rpc[AGENT_METHODS.session_new](payload)), + loadSession: (payload) => callRpc(rpc[AGENT_METHODS.session_load](payload)), + listSessions: (payload) => callRpc(rpc[AGENT_METHODS.session_list](payload)), + forkSession: (payload) => callRpc(rpc[AGENT_METHODS.session_fork](payload)), + resumeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_resume](payload)), + closeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_close](payload)), + setSessionModel: (payload) => callRpc(rpc[AGENT_METHODS.session_set_model](payload)), + setSessionConfigOption: (payload) => + callRpc(rpc[AGENT_METHODS.session_set_config_option](payload)), + prompt: (payload) => callRpc(rpc[AGENT_METHODS.session_prompt](payload)), + cancel: (payload) => transport.notify(AGENT_METHODS.session_cancel, payload), + }, handleRequestPermission: (handler) => Effect.suspend(() => { coreHandlers.requestPermission = handler; @@ -622,136 +527,14 @@ export const makeFromChildProcessHandle = Effect.fn("makeFromChildProcessHandle" ); return Effect.void; }), - initialize: (payload) => callRpc(rpc[AGENT_METHODS.initialize](payload)), - authenticate: (payload) => callRpc(rpc[AGENT_METHODS.authenticate](payload)), - logout: (payload) => callRpc(rpc[AGENT_METHODS.logout](payload)), - createSession: (payload) => callRpc(rpc[AGENT_METHODS.session_new](payload)), - loadSession: (payload) => callRpc(rpc[AGENT_METHODS.session_load](payload)), - listSessions: (payload) => callRpc(rpc[AGENT_METHODS.session_list](payload)), - forkSession: (payload) => callRpc(rpc[AGENT_METHODS.session_fork](payload)), - resumeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_resume](payload)), - closeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_close](payload)), - setSessionMode: (payload) => callRpc(rpc[AGENT_METHODS.session_set_mode](payload)), - setSessionModel: (payload) => callRpc(rpc[AGENT_METHODS.session_set_model](payload)), - setSessionConfigOption: (payload) => - callRpc(rpc[AGENT_METHODS.session_set_config_option](payload)), - prompt: (payload) => callRpc(rpc[AGENT_METHODS.session_prompt](payload)), - cancel: (payload) => transport.notify("session/cancel", payload), - request: transport.request, - notify: transport.notify, - requestPermission: (payload) => - request( - CLIENT_METHODS.session_request_permission, - payload, - ), - elicit: (payload) => - request(CLIENT_METHODS.session_elicitation, payload), - readTextFile: (payload) => - request(CLIENT_METHODS.fs_read_text_file, payload), - writeTextFile: (payload) => - request(CLIENT_METHODS.fs_write_text_file, payload).pipe( - Effect.map((response) => response ?? {}), - ), - createTerminal: (payload) => - request(CLIENT_METHODS.terminal_create, payload).pipe( - Effect.map((response) => - AcpTerminal.makeTerminal({ - sessionId: payload.sessionId, - terminalId: response.terminalId, - output: request(CLIENT_METHODS.terminal_output, { - sessionId: payload.sessionId, - terminalId: response.terminalId, - }), - waitForExit: request( - CLIENT_METHODS.terminal_wait_for_exit, - { - sessionId: payload.sessionId, - terminalId: response.terminalId, - }, - ), - kill: request(CLIENT_METHODS.terminal_kill, { - sessionId: payload.sessionId, - terminalId: response.terminalId, - }).pipe(Effect.map((result) => result ?? {})), - release: request(CLIENT_METHODS.terminal_release, { - sessionId: payload.sessionId, - terminalId: response.terminalId, - }).pipe(Effect.map((result) => result ?? {})), - }), - ), - ), - sessionUpdate: (payload) => transport.notify(CLIENT_METHODS.session_update, payload), - elicitationComplete: (payload) => - transport.notify(CLIENT_METHODS.session_elicitation_complete, payload), - } satisfies AcpConnectionShape); + }); }); -export const layerFromChildProcessHandle = ( +export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, - options: AcpConnectionOptions = {}, -): Layer.Layer => - Layer.effect(AcpConnection, makeFromChildProcessHandle(handle, options)); - -const runHandler = Effect.fnUntraced(function* ( - handler: ((payload: A) => Effect.Effect) | undefined, - payload: A, - method: string, -) { - if (!handler) { - return yield* AcpError.AcpRequestError.methodNotFound(method); - } - return yield* handler(payload).pipe( - Effect.mapError((error) => - Schema.is(AcpError.AcpRequestError)(error) - ? error.toProtocolError() - : AcpError.AcpRequestError.internalError(error.message).toProtocolError(), - ), - ); -}); - -function decodeExtRequestRegistration( - method: string, - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, -) { - return (params: unknown): Effect.Effect => - Schema.decodeUnknownEffect(payload)(params).pipe( - Effect.mapError((error) => - AcpError.AcpRequestError.invalidParams( - `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`, - { issue: error.issue }, - ), - ), - Effect.flatMap((decoded) => handler(decoded)), - ); -} - -function decodeExtNotificationRegistration( - method: string, - payload: Schema.Codec, - handler: (payload: A) => Effect.Effect, -) { - return (params: unknown): Effect.Effect => - Schema.decodeUnknownEffect(payload)(params).pipe( - Effect.mapError( - (error) => - new AcpError.AcpProtocolParseError({ - detail: `Invalid ${method} notification payload: ${formatSchemaIssue(error.issue)}`, - cause: error, - }), - ), - Effect.flatMap((decoded) => handler(decoded)), - ); -} - -function makeStdioFromChildProcess(handle: ChildProcessSpawner.ChildProcessHandle): Stdio.Stdio { - return Stdio.make({ - args: Effect.succeed([]), - stdin: handle.stdout, - stdout: () => - Sink.mapInput(handle.stdin, (chunk: string | Uint8Array) => - typeof chunk === "string" ? textEncoder.encode(chunk) : chunk, - ), - stderr: () => Sink.drain, - }); -} + options: AcpClientOptions = {}, +): Layer.Layer => { + const stdio = makeChildStdio(handle); + const terminationError = makeTerminationError(handle); + return Layer.effect(AcpClient, make(stdio, options, terminationError)); +}; diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index b105b04865f..5ffdfa2bdb7 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -1,49 +1,41 @@ import * as Path from "effect/Path"; import * as AcpError from "./errors"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as Sink from "effect/Sink"; -import * as Stdio from "effect/Stdio"; import * as Ref from "effect/Ref"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { it, assert } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as AcpSchema from "./_generated/schema.gen"; import * as AcpProtocol from "./protocol"; - -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); -const UnknownJson = Schema.UnknownFromJsonString; - -const encodeJson = Schema.encodeSync(UnknownJson); -const decodeJson = Schema.decodeUnknownSync(UnknownJson); - -function makeInMemoryStdio() { - return Effect.gen(function* () { - const input = yield* Queue.unbounded>(); - const output = yield* Queue.unbounded(); - - return { - stdio: Stdio.make({ - args: Effect.succeed([]), - stdin: Stream.fromQueue(input), - stdout: () => - Sink.forEach((chunk: string | Uint8Array) => - Queue.offer(output, typeof chunk === "string" ? chunk : decoder.decode(chunk)), - ), - stderr: () => Sink.drain, - }), - input, - output, - }; - }); -} +import { + encodeJsonl, + jsonRpcNotification, + jsonRpcRequest, + jsonRpcResponse, +} from "./_internal/shared"; +import { makeInMemoryStdio, makeTerminationError, makeChildStdio } from "./_internal/stdio"; + +const SessionCancelNotification = jsonRpcNotification( + "session/cancel", + AcpSchema.CancelNotification, +); +const SessionUpdateNotification = jsonRpcNotification( + "session/update", + AcpSchema.SessionNotification, +); +const ElicitationCompleteNotification = jsonRpcNotification( + "session/elicitation/complete", + AcpSchema.ElicitationCompleteNotification, +); +const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); +const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), @@ -61,32 +53,6 @@ const makeHandle = (env?: Record) => return yield* spawner.spawn(command); }); -function makeChildStdio(handle: ChildProcessSpawner.ChildProcessHandle) { - return Stdio.make({ - args: Effect.succeed([]), - stdin: handle.stdout, - stdout: () => - Sink.mapInput(handle.stdin, (chunk: string | Uint8Array) => - typeof chunk === "string" ? encoder.encode(chunk) : chunk, - ), - stderr: () => Sink.drain, - }); -} - -function makeProcessExit( - handle: ChildProcessSpawner.ChildProcessHandle, -): Effect.Effect { - return handle.exitCode.pipe( - Effect.map(Number), - Effect.mapError( - (cause) => - new AcpError.AcpProcessExitedError({ - cause, - }), - ), - ); -} - it.layer(NodeServices.layer)("effect-acp protocol", (it) => { it.effect( "emits exact JSON-RPC notifications and decodes inbound session/update and elicitation completion", @@ -109,50 +75,47 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { yield* transport.notify("session/cancel", { sessionId: "session-1" }); const outbound = yield* Queue.take(output); - assert.deepEqual(decodeJson(outbound), { - jsonrpc: "2.0", - id: "", - headers: [], - method: "session/cancel", - params: { - sessionId: "session-1", + assert.deepEqual( + yield* Schema.decodeEffect(Schema.fromJsonString(SessionCancelNotification))(outbound), + { + jsonrpc: "2.0", + method: "session/cancel", + params: { + sessionId: "session-1", + }, }, - }); + ); yield* Queue.offer( input, - encoder.encode( - `${encodeJson({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "session-1", - update: { - sessionUpdate: "plan", - entries: [ - { - content: "Inspect repository", - priority: "high", - status: "in_progress", - }, - ], - }, + yield* encodeJsonl(SessionUpdateNotification, { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Inspect repository", + priority: "high", + status: "in_progress", + }, + ], }, - })}\n`, - ), + }, + }), ); yield* Queue.offer( input, - encoder.encode( - `${encodeJson({ - jsonrpc: "2.0", - method: "session/elicitation/complete", - params: { - elicitationId: "elicitation-1", - }, - })}\n`, - ), + yield* encodeJsonl(ElicitationCompleteNotification, { + jsonrpc: "2.0", + method: "session/elicitation/complete", + params: { + elicitationId: "elicitation-1", + }, + }), ); const [update, completion] = yield* Deferred.await(notifications); @@ -233,7 +196,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { .request("x/test", { hello: "world" }) .pipe(Effect.forkScoped); const outbound = yield* Queue.take(output); - assert.deepEqual(decodeJson(outbound), { + assert.deepEqual(yield* Schema.decodeEffect(Schema.fromJsonString(ExtRequest))(outbound), { jsonrpc: "2.0", id: 1, method: "x/test", @@ -245,15 +208,13 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { yield* Queue.offer( input, - encoder.encode( - `${encodeJson({ - jsonrpc: "2.0", - id: 1, - result: { - ok: true, - }, - })}\n`, - ), + yield* encodeJsonl(ExtResponse, { + jsonrpc: "2.0", + id: 1, + result: { + ok: true, + }, + }), ); const resolved = yield* Fiber.join(response); @@ -278,7 +239,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { .request("x/test", { hello: "world" }) .pipe(Effect.forkScoped); const outbound = yield* Queue.take(output); - assert.deepEqual(decodeJson(outbound), { + assert.deepEqual(yield* Schema.decodeEffect(Schema.fromJsonString(ExtRequest))(outbound), { jsonrpc: "2.0", id: 1, method: "x/test", @@ -291,15 +252,13 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { yield* Fiber.interrupt(response); yield* Queue.offer( input, - encoder.encode( - `${encodeJson({ - jsonrpc: "2.0", - id: 1, - result: { - ok: true, - }, - })}\n`, - ), + yield* encodeJsonl(ExtResponse, { + jsonrpc: "2.0", + id: 1, + result: { + ok: true, + }, + }), ); const message = yield* Deferred.await(lateResponse); @@ -320,12 +279,12 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { Effect.gen(function* () { const handle = yield* makeHandle({ ACP_MOCK_EXIT_IMMEDIATELY_CODE: "7" }); const firstMessage = yield* Deferred.make(); - const processExit = yield* Deferred.make(); + const termination = yield* Deferred.make(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio: makeChildStdio(handle), - processExit: makeProcessExit(handle), + terminationError: makeTerminationError(handle), serverRequestMethods: new Set(), - onProcessExit: (error) => Deferred.succeed(processExit, error).pipe(Effect.asVoid), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), }); yield* transport.clientProtocol @@ -333,9 +292,9 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { .pipe(Effect.forkScoped); const message = yield* Deferred.await(firstMessage); - const exitError = yield* Deferred.await(processExit); + const exitError = yield* Deferred.await(termination); assert.instanceOf(exitError, AcpError.AcpProcessExitedError); - assert.equal(exitError.code, 7); + assert.equal((exitError as AcpError.AcpProcessExitedError).code, 7); assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { readonly _tag: string; @@ -353,13 +312,13 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { ACP_MOCK_MALFORMED_OUTPUT: "1", ACP_MOCK_MALFORMED_OUTPUT_EXIT_CODE: "23", }); - const processExitCalls = yield* Ref.make(0); + const terminationCalls = yield* Ref.make(0); const firstMessage = yield* Deferred.make(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio: makeChildStdio(handle), - processExit: makeProcessExit(handle), + terminationError: makeTerminationError(handle), serverRequestMethods: new Set(), - onProcessExit: () => Ref.update(processExitCalls, (count) => count + 1), + onTermination: () => Ref.update(terminationCalls, (count) => count + 1), }); yield* transport.clientProtocol @@ -367,7 +326,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { .pipe(Effect.forkScoped); const message = yield* Deferred.await(firstMessage); - assert.equal(yield* Ref.get(processExitCalls), 0); + assert.equal(yield* Ref.get(terminationCalls), 1); assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { readonly _tag: string; @@ -383,7 +342,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { const { stdio, input, output } = yield* makeInMemoryStdio(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, - processExit: Effect.succeed(0), + terminationError: Effect.succeed(new AcpError.AcpProcessExitedError({ code: 0 })), serverRequestMethods: new Set(), }); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 51ae5553c0f..210ad4ee28a 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -42,7 +42,7 @@ export type AcpIncomingNotification = export interface AcpPatchedProtocolOptions { readonly stdio: Stdio.Stdio; - readonly processExit?: Effect.Effect; + readonly terminationError?: Effect.Effect; readonly serverRequestMethods: ReadonlySet; readonly logIncoming?: boolean; readonly logOutgoing?: boolean; @@ -54,9 +54,7 @@ export interface AcpPatchedProtocolOptions { method: string, params: unknown, ) => Effect.Effect; - readonly onProcessExit?: ( - error: AcpError.AcpProcessExitedError, - ) => Effect.Effect; + readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect; } export interface AcpPatchedProtocol { @@ -189,15 +187,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi }), }).pipe(Effect.asVoid); - const handleTermination = ( - classify: () => Effect.Effect< - | { - readonly error: AcpError.AcpError; - readonly processExitError?: AcpError.AcpProcessExitedError | undefined; - } - | undefined - >, - ) => + const handleTermination = (classify: () => Effect.Effect) => Ref.modify(terminationHandled, (handled) => { if (handled) { return [Effect.void, true] as const; @@ -205,14 +195,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi return [ Effect.gen(function* () { yield* Queue.offer(disconnects, 0); - const terminated = yield* classify(); - if (!terminated) { + const error = yield* classify(); + if (!error) { return; } - yield* failAllExtPending(terminated.error); - yield* emitClientProtocolError(terminated.error); - if (terminated.processExitError && options.onProcessExit) { - yield* options.onProcessExit(terminated.processExitError); + yield* failAllExtPending(error); + yield* emitClientProtocolError(error); + if (options.onTermination) { + yield* options.onTermination(error); } }), true, @@ -423,36 +413,18 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi detail: error instanceof Error ? error.message : String(error), cause: error, }); - return handleTermination(() => Effect.succeed({ error: normalized })); + return handleTermination(() => Effect.succeed(normalized)); }, onSuccess: () => - handleTermination(() => - options.processExit - ? options.processExit.pipe( - Effect.match({ - onFailure: (processExitError) => - ({ - error: processExitError, - processExitError, - }) as const, - onSuccess: (code) => { - const processExitError = - code === null - ? new AcpError.AcpProcessExitedError({}) - : new AcpError.AcpProcessExitedError({ code }); - return { - error: processExitError, - processExitError, - } as const; - }, - }), - ) - : Effect.succeed({ - error: new AcpError.AcpTransportError({ - detail: "ACP input stream ended", - cause: new Error("ACP input stream ended"), - }), + handleTermination( + () => + options.terminationError ?? + Effect.succeed( + new AcpError.AcpTransportError({ + detail: "ACP input stream ended", + cause: new Error("ACP input stream ended"), }), + ), ), }), Effect.forkScoped, diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts index b3b5af1a7c5..4d479f791c4 100644 --- a/packages/effect-acp/src/rpc.ts +++ b/packages/effect-acp/src/rpc.ts @@ -58,12 +58,6 @@ export const CloseSessionRpc = Rpc.make(AGENT_METHODS.session_close, { error: AcpSchema.Error, }); -export const SetSessionModeRpc = Rpc.make(AGENT_METHODS.session_set_mode, { - payload: AcpSchema.SetSessionModeRequest, - success: AcpSchema.SetSessionModeResponse, - error: AcpSchema.Error, -}); - export const PromptRpc = Rpc.make(AGENT_METHODS.session_prompt, { payload: AcpSchema.PromptRequest, success: AcpSchema.PromptResponse, @@ -146,7 +140,6 @@ export const AgentRpcs = RpcGroup.make( ForkSessionRpc, ResumeSessionRpc, CloseSessionRpc, - SetSessionModeRpc, PromptRpc, SetSessionModelRpc, SetSessionConfigOptionRpc, diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index 5b8ef676635..b87aa582f85 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -6,20 +6,20 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as AcpClient from "../../src/client"; -Effect.gen(function* () { +const program = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const command = ChildProcess.make("cursor-agent", ["acp"], { cwd: process.cwd(), shell: process.platform === "win32", }); const handle = yield* spawner.spawn(command); - const acpLayer = AcpClient.layerFromChildProcessHandle(handle, { + const acpLayer = AcpClient.layerChildProcess(handle, { logIncoming: true, logOutgoing: true, }); yield* Effect.gen(function* () { - const acp = yield* AcpClient.AcpConnection; + const acp = yield* AcpClient.AcpClient; yield* acp.handleRequestPermission(() => Effect.succeed({ @@ -33,7 +33,7 @@ Effect.gen(function* () { Effect.logInfo("session/update", notification), ); - const initialized = yield* acp.initialize({ + const initialized = yield* acp.agent.initialize({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, @@ -46,18 +46,18 @@ Effect.gen(function* () { }); yield* Effect.logInfo("initialized", initialized); - const session = yield* acp.createSession({ + const session = yield* acp.agent.createSession({ cwd: process.cwd(), mcpServers: [], }); - yield* acp.setSessionConfigOption({ + yield* acp.agent.setSessionConfigOption({ sessionId: session.sessionId, configId: "model", value: "gpt-5.4[reasoning=medium,context=272k,fast=false]", }); - const result = yield* acp.prompt({ + const result = yield* acp.agent.prompt({ sessionId: session.sessionId, prompt: [ { @@ -68,6 +68,8 @@ Effect.gen(function* () { }); yield* Effect.logInfo("prompt result", result); - yield* acp.cancel({ sessionId: session.sessionId }); + yield* acp.agent.cancel({ sessionId: session.sessionId }); }).pipe(Effect.provide(acpLayer)); -}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); +}); + +program.pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); diff --git a/packages/effect-acp/test/fixtures/acp-mock-peer.ts b/packages/effect-acp/test/fixtures/acp-mock-peer.ts index 12ea2a4b8f0..271694719df 100644 --- a/packages/effect-acp/test/fixtures/acp-mock-peer.ts +++ b/packages/effect-acp/test/fixtures/acp-mock-peer.ts @@ -1,9 +1,10 @@ -import { createInterface } from "node:readline"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; -const rl = createInterface({ - input: process.stdin, - crlfDelay: Infinity, -}); +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; + +import * as AcpAgent from "../../src/agent"; if (process.env.ACP_MOCK_MALFORMED_OUTPUT === "1") { process.stdout.write("{not-json}\n"); @@ -14,124 +15,49 @@ if (process.env.ACP_MOCK_EXIT_IMMEDIATELY_CODE !== undefined) { process.exit(Number(process.env.ACP_MOCK_EXIT_IMMEDIATELY_CODE)); } -let nextRequestId = 1000; -const pending = new Map< - number | string, - { resolve: (value: unknown) => void; reject: (error: unknown) => void } ->(); - -function writeMessage(message: unknown) { - process.stdout.write(`${JSON.stringify(message)}\n`); -} - -function errorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - if ( - typeof error === "object" && - error !== null && - "message" in error && - typeof error.message === "string" - ) { - return error.message; - } - return String(error); -} +const sessionId = "mock-session-1"; -function respond(id: number | string | null | undefined, result: unknown) { - writeMessage({ - jsonrpc: "2.0", - id, - result, - }); -} +const program = Effect.gen(function* () { + const agent = yield* AcpAgent.AcpAgent; -function respondError( - id: number | string | null | undefined, - code: number, - message: string, - data?: unknown, -) { - writeMessage({ - jsonrpc: "2.0", - id, - error: { - code, - message, - ...(data !== undefined ? { data } : {}), - }, - }); -} - -function notify(method: string, params?: unknown) { - writeMessage({ - jsonrpc: "2.0", - method, - ...(params !== undefined ? { params } : {}), - }); -} - -function requestClient(method: string, params?: unknown) { - const id = nextRequestId++; - writeMessage({ - jsonrpc: "2.0", - id, - method, - ...(params !== undefined ? { params } : {}), - }); - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - }); -} - -async function handleRequest(message: { - readonly id: number | string | null; - readonly method: string; - readonly params?: unknown; -}) { - switch (message.method) { - case "initialize": - respond(message.id, { - protocolVersion: 1, - agentCapabilities: { - sessionCapabilities: { - list: {}, - }, + yield* agent.handleInitialize(() => + Effect.succeed({ + protocolVersion: 1, + agentCapabilities: { + sessionCapabilities: { + list: {}, }, - agentInfo: { - name: "mock-agent", - version: "0.0.0", + }, + agentInfo: { + name: "mock-agent", + version: "0.0.0", + }, + }), + ); + + yield* agent.handleAuthenticate(() => Effect.succeed({})); + yield* agent.handleLogout(() => Effect.succeed({})); + yield* agent.handleCreateSession(() => + Effect.succeed({ + sessionId, + }), + ); + yield* agent.handleLoadSession(() => Effect.succeed({})); + yield* agent.handleListSessions(() => + Effect.succeed({ + sessions: [ + { + sessionId, + cwd: process.cwd(), }, - }); - return; - case "authenticate": - respond(message.id, {}); - return; - case "logout": - respond(message.id, {}); - return; - case "session/new": - respond(message.id, { - sessionId: "mock-session-1", - }); - return; - case "session/load": - respond(message.id, {}); - return; - case "session/list": - respond(message.id, { - sessions: [ - { - sessionId: "mock-session-1", - cwd: process.cwd(), - }, - ], - }); - return; - case "session/prompt": { - await requestClient("session/request_permission", { - sessionId: "mock-session-1", + ], + }), + ); + + yield* agent.handlePrompt(() => + Effect.gen(function* () { + yield* agent.client.requestPermission({ + sessionId, options: [ { optionId: "allow", @@ -145,8 +71,8 @@ async function handleRequest(message: { }, }); - await requestClient("session/elicitation", { - sessionId: "mock-session-1", + yield* agent.client.elicit({ + sessionId, message: "Need confirmation before continuing.", mode: "form", requestedSchema: { @@ -162,8 +88,8 @@ async function handleRequest(message: { }, }); - notify("session/update", { - sessionId: "mock-session-1", + yield* agent.client.sessionUpdate({ + sessionId, update: { sessionUpdate: "plan", entries: [ @@ -176,81 +102,35 @@ async function handleRequest(message: { }, }); - notify("session/elicitation/complete", { + yield* agent.client.elicitationComplete({ elicitationId: "elicitation-1", }); - await requestClient("x/typed_request", { + yield* agent.client.extRequest("x/typed_request", { message: process.env.ACP_MOCK_BAD_TYPED_REQUEST === "1" ? 123 : "hello from typed request", }); - notify("x/typed_notification", { + yield* agent.client.extNotification("x/typed_notification", { count: 2, }); - respond(message.id, { - stopReason: "end_turn", - }); - return; - } - default: - respond(message.id, { - echoedMethod: message.method, - echoedParams: message.params ?? null, - }); - return; - } -} - -function handleResponse(message: { - readonly id: number | string | null; - readonly result?: unknown; - readonly error?: { readonly code: number; readonly message: string; readonly data?: unknown }; -}) { - const pendingRequest = pending.get(message.id ?? ""); - if (!pendingRequest) { - return; - } - pending.delete(message.id ?? ""); - if (message.error) { - pendingRequest.reject(message.error); - } else { - pendingRequest.resolve(message.result); - } -} - -rl.on("line", (line) => { - const trimmed = line.trim(); - if (trimmed.length === 0) { - return; - } + return { + stopReason: "end_turn" as const, + }; + }), + ); - const message = JSON.parse(trimmed) as - | { readonly id: number | string | null; readonly method: string; readonly params?: unknown } - | { - readonly id: number | string | null; - readonly result?: unknown; - readonly error?: { - readonly code: number; - readonly message: string; - readonly data?: unknown; - }; - } - | { readonly method: string; readonly params?: unknown }; + yield* agent.handleUnknownExtRequest((method, params) => + Effect.succeed({ + echoedMethod: method, + echoedParams: params ?? null, + }), + ); - if ("method" in message && "id" in message) { - void handleRequest(message).catch((error) => { - respondError(message.id, -32603, errorMessage(error)); - }); - return; - } - - if ("id" in message && ("result" in message || "error" in message)) { - handleResponse(message); - return; - } - - if ("method" in message && !("id" in message)) { - return; - } + return yield* Effect.never; }); + +program.pipe( + Effect.provide(Layer.provide(AcpAgent.layerStdio(), NodeServices.layer)), + NodeRuntime.runMain, +); From e44eea6de74c9931a1d05c2004f3fb456656a2e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 21:30:12 -0700 Subject: [PATCH 41/82] fix --- apps/desktop/package.json | 1 + bun.lock | 1 + scripts/release-smoke.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 188e701e7ec..ebc9cae87ff 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,6 +22,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@types/node": "catalog:", + "effect-acp": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:", diff --git a/bun.lock b/bun.lock index 670bf355fce..c8c33e0092c 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@types/node": "catalog:", + "effect-acp": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:", diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index bf9d9f5c6a2..5420c775f19 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -15,6 +15,7 @@ const workspaceFiles = [ "apps/marketing/package.json", "packages/contracts/package.json", "packages/shared/package.json", + "packages/effect-acp/package.json", "scripts/package.json", ] as const; From 399be256889295da83f2e37b80c57f4895a5e6f5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 21:39:16 -0700 Subject: [PATCH 42/82] kewl --- packages/effect-acp/src/protocol.ts | 44 +++++++++++++++-------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 210ad4ee28a..5e3128fe021 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -99,32 +99,34 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi ); }; - const offerOutgoing = (message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded) => - Effect.try({ + const offerOutgoing = Effect.fn("offerOutgoing")(function* ( + message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, + ) { + yield* logProtocol({ + direction: "outgoing", + stage: "decoded", + payload: message, + }); + + const encoded = yield* Effect.try({ try: () => parser.encode(message), catch: (cause) => new AcpError.AcpProtocolParseError({ detail: "Failed to encode ACP message", cause, }), - }).pipe( - Effect.tap(() => - logProtocol({ - direction: "outgoing", - stage: "decoded", - payload: message, - }), - ), - Effect.flatMap((encoded) => - encoded === undefined - ? Effect.void - : logProtocol({ - direction: "outgoing", - stage: "raw", - payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded), - }).pipe(Effect.flatMap(() => Queue.offer(outgoing, encoded).pipe(Effect.asVoid))), - ), - ); + }); + + if (encoded) { + yield* logProtocol({ + direction: "outgoing", + stage: "raw", + payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded), + }); + + yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid); + } + }); const resolveExtPending = ( requestId: string, @@ -451,7 +453,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi ), disconnects, send: (_clientId, response) => offerOutgoing(response).pipe(Effect.orDie), - end: () => Queue.end(outgoing).pipe(Effect.orDie), + end: () => Queue.end(outgoing), clientIds: Effect.succeed(new Set([0])), initialMessage: Effect.succeedNone, supportsAck: true, From ece6436bfba61529e9e4762e79a908949e688729 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 21:56:47 -0700 Subject: [PATCH 43/82] fixes --- apps/server/scripts/acp-mock-agent.ts | 92 ++++ .../Layers/ProviderCommandReactor.test.ts | 56 +- .../Layers/ProviderCommandReactor.ts | 61 ++- .../Layers/ProviderRuntimeIngestion.test.ts | 516 +++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 346 +++++++++++- .../src/provider/Layers/CursorAdapter.test.ts | 509 ++++++++++++----- .../src/provider/Layers/CursorAdapter.ts | 57 +- .../provider/acp/AcpCoreRuntimeEvents.test.ts | 21 + .../src/provider/acp/AcpCoreRuntimeEvents.ts | 24 + .../provider/acp/AcpJsonRpcConnection.test.ts | 122 ++++- .../src/provider/acp/AcpRuntimeModel.test.ts | 4 +- .../src/provider/acp/AcpRuntimeModel.ts | 48 +- .../src/provider/acp/AcpSessionRuntime.ts | 140 ++++- bun.lock | 3 + package.json | 3 + packages/effect-acp/src/protocol.test.ts | 85 ++- .../examples/cursor-acp-client.example.ts | 7 +- packages/shared/package.json | 4 + packages/shared/src/toolActivity.test.ts | 57 ++ packages/shared/src/toolActivity.ts | 257 +++++++++ patches/effect@4.0.0-beta.42.patch | 96 ++++ 21 files changed, 2310 insertions(+), 198 deletions(-) create mode 100644 packages/shared/src/toolActivity.test.ts create mode 100644 packages/shared/src/toolActivity.ts create mode 100644 patches/effect@4.0.0-beta.42.patch diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 8b0adaf704b..de96c999264 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -12,6 +12,9 @@ import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const emitInterleavedAssistantToolCalls = + process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; +const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; @@ -136,6 +139,56 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); + if (emitInterleavedAssistantToolCalls) { + const toolCallId = "tool-call-1"; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "before tool" }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + command: ["echo", "hello"], + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "completed", + rawOutput: { + exitCode: 0, + stdout: "hello", + stderr: "", + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "after tool" }, + }, + }); + + return { stopReason: "end_turn" }; + } + if (emitToolCalls) { const toolCallId = "tool-call-1"; @@ -217,6 +270,45 @@ const program = Effect.gen(function* () { return { stopReason: cancelled ? "cancelled" : "end_turn" }; } + if (emitGenericToolPlaceholders) { + const toolCallId = "tool-call-generic-1"; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Read File", + kind: "read", + status: "pending", + rawInput: {}, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "completed", + rawOutput: { + content: "package.json\n", + }, + }, + }); + + return { stopReason: "end_turn" }; + } + if (emitAskQuestion) { yield* agent.client.extRequest("cursor/ask_question", { toolCallId: "ask-question-tool-call-1", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 570bdc8ab3b..016b2306b97 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -13,7 +13,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import { Deferred, Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; import { deriveServerPaths, ServerConfig } from "../../config.ts"; @@ -1298,6 +1298,60 @@ describe("ProviderCommandReactor", () => { }); }); + it("processes approval responses while a provider turn is still in flight", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + const unblockSendTurn = Effect.runSync(Deferred.make()); + + harness.sendTurn.mockImplementation(() => + Deferred.await(unblockSendTurn).pipe( + Effect.as({ + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-1"), + }), + ), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-blocked-approval"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-blocked-approval"), + role: "user", + text: "need approval while turn is running", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe("cmd-approval-respond-during-send-turn"), + threadId: ThreadId.makeUnsafe("thread-1"), + requestId: asApprovalRequestId("approval-request-while-send-turn-blocked"), + decision: "acceptForSession", + createdAt: now, + }), + ); + + await waitFor(() => harness.respondToRequest.mock.calls.length === 1); + expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ + threadId: "thread-1", + requestId: "approval-request-while-send-turn-blocked", + decision: "acceptForSession", + }); + + await Effect.runPromise(Deferred.succeed(unblockSendTurn, undefined)); + }); + it("reacts to thread.user-input.respond by forwarding structured user input answers", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 49efb5a9501..8f014ef6fea 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -382,7 +382,7 @@ const make = Effect.gen(function* () { return startedSession.threadId; }); - const sendTurnForThread = Effect.fnUntraced(function* (input: { + const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; readonly messageText: string; readonly attachments?: ReadonlyArray; @@ -392,7 +392,9 @@ const make = Effect.gen(function* () { }) { const thread = yield* resolveThread(input.threadId); if (!thread) { - return; + return yield* Effect.die( + new Error(`Thread '${input.threadId}' was not found in read model.`), + ); } yield* ensureSessionForThread( input.threadId, @@ -425,13 +427,13 @@ const make = Effect.gen(function* () { : requestedModelSelection : input.modelSelection; - yield* providerService.sendTurn({ + return { threadId: input.threadId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), - }); + }; }); const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { @@ -532,7 +534,27 @@ const make = Effect.gen(function* () { ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), }).pipe(Effect.forkScoped); - yield* sendTurnForThread({ + const handleTurnStartFailure = (cause: Cause.Cause) => { + const detail = formatFailureDetail(cause); + return setThreadSessionErrorOnTurnStartFailure({ + threadId: event.payload.threadId, + detail, + createdAt: event.payload.createdAt, + }).pipe( + Effect.flatMap(() => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + detail, + turnId: null, + createdAt: event.payload.createdAt, + }), + ), + ); + }; + + const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), @@ -542,26 +564,17 @@ const make = Effect.gen(function* () { interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( - Effect.catchCause((cause) => { - const detail = formatFailureDetail(cause); - return setThreadSessionErrorOnTurnStartFailure({ - threadId: event.payload.threadId, - detail, - createdAt: event.payload.createdAt, - }).pipe( - Effect.flatMap(() => - appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.turn.start.failed", - summary: "Provider turn start failed", - detail, - turnId: null, - createdAt: event.payload.createdAt, - }), - ), - ); - }), + Effect.map(Option.some), + Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))), ); + + if (Option.isNone(sendTurnRequest)) { + return; + } + + yield* providerService + .sendTurn(sendTurnRequest.value) + .pipe(Effect.catchCause(handleTurnStartFailure), Effect.forkScoped); }); const processTurnInterruptRequested = Effect.fnUntraced(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 88473d7c929..c050331765e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -707,8 +707,7 @@ describe("ProviderRuntimeIngestion", () => { payload: { itemType: "dynamic_tool_call", status: "completed", - title: "Read File", - detail: "Read File", + title: "Read file", data: { toolCallId: "tool-read-1", kind: "read", @@ -741,13 +740,98 @@ describe("ProviderRuntimeIngestion", () => { : undefined; expect(activity?.kind).toBe("tool.completed"); + expect(activity?.summary).toBe("Read file"); expect(payload?.itemType).toBe("dynamic_tool_call"); - expect(payload?.detail).toBe("Read File"); + expect(payload?.detail).toBeUndefined(); expect(data?.toolCallId).toBe("tool-read-1"); expect(data?.kind).toBe("read"); expect(rawOutput?.content).toBe('import * as Effect from "effect/Effect"\n'); }); + it("normalizes command execution activities to ran-command summaries", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-command-completed"), + provider: "cursor", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-command-completed"), + itemId: asItemId("item-command-completed"), + payload: { + itemType: "command_execution", + status: "completed", + title: "Ran command", + detail: "bun run lint", + data: { + toolCallId: "tool-command-1", + kind: "execute", + command: "bun run lint", + }, + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-command-completed", + ), + ); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.id === "evt-command-completed", + ); + const payload = + activity?.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : undefined; + + expect(activity?.summary).toBe("Ran command"); + expect(payload?.detail).toBe("bun run lint"); + }); + + it("uses structured read-file paths when available", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-read-path-completed"), + provider: "cursor", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-read-path"), + itemId: asItemId("item-read-path"), + payload: { + itemType: "dynamic_tool_call", + status: "completed", + title: "Read file", + detail: "/tmp/app.ts", + data: { + toolCallId: "tool-read-path-1", + kind: "read", + locations: [{ path: "/tmp/app.ts" }], + }, + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-read-path-completed", + ), + ); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.id === "evt-read-path-completed", + ); + const payload = + activity?.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : undefined; + + expect(activity?.summary).toBe("Read file"); + expect(payload?.detail).toBe("/tmp/app.ts"); + }); + it("projects completed plan items into first-class proposed plans", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1419,6 +1503,432 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("flushes and completes buffered assistant text when an approval request opens", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-buffered-request-flush"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-flush"), + }); + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-buffered-request-flush", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-buffered-request-flush"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-flush"), + itemId: asItemId("item-buffered-request-flush"), + payload: { + streamKind: "assistant_text", + delta: "visible before approval", + }, + }); + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened-buffered-request-flush"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-flush"), + requestId: ApprovalRequestId.makeUnsafe("req-buffered-request-flush"), + payload: { + requestType: "command_execution_approval", + detail: "pwd", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered-request-flush" && + !message.streaming && + message.text === "visible before approval", + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered-request-flush", + ); + expect(message?.streaming).toBe(false); + }); + + it("flushes and completes buffered assistant text when user input is requested", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-buffered-user-input-flush"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-user-input-flush"), + }); + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-buffered-user-input-flush", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-buffered-user-input-flush"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-user-input-flush"), + itemId: asItemId("item-buffered-user-input-flush"), + payload: { + streamKind: "assistant_text", + delta: "visible before user input", + }, + }); + harness.emit({ + type: "user-input.requested", + eventId: asEventId("evt-user-input-requested-buffered-user-input-flush"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-user-input-flush"), + requestId: ApprovalRequestId.makeUnsafe("req-buffered-user-input-flush"), + payload: { + questions: [ + { + id: "choice", + header: "Choice", + question: "Pick one", + options: [{ label: "A", description: "Option A" }], + }, + ], + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered-user-input-flush" && + !message.streaming && + message.text === "visible before user input", + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => + entry.id === "assistant:item-buffered-user-input-flush", + ); + expect(message?.streaming).toBe(false); + }); + + it("does not create assistant segments for whitespace-only buffered text at approval boundaries", async () => { + const harness = await createHarness(); + const startedAt = "2026-03-28T06:28:00.000Z"; + const pausedAt = "2026-03-28T06:28:01.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-buffered-whitespace-request"), + provider: "codex", + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-whitespace-request"), + }); + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-buffered-whitespace-request", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-buffered-whitespace-request"), + provider: "codex", + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-whitespace-request"), + itemId: asItemId("item-buffered-whitespace-request"), + payload: { + streamKind: "assistant_text", + delta: "\n\n\n", + }, + }); + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened-buffered-whitespace-request"), + provider: "codex", + createdAt: pausedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-whitespace-request"), + requestId: ApprovalRequestId.makeUnsafe("req-buffered-whitespace-request"), + payload: { + requestType: "command_execution_approval", + detail: "pwd", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested", + ), + ); + expect( + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered-whitespace-request", + ), + ).toBe(false); + }); + + it("starts a new buffered assistant message segment after approval and completes without duplication", async () => { + const harness = await createHarness(); + const startedAt = "2026-03-28T06:07:00.000Z"; + const pausedAt = "2026-03-28T06:07:01.000Z"; + const resumedAt = "2026-03-28T06:07:02.000Z"; + const completedAt = "2026-03-28T06:07:03.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-buffered-request-append"), + provider: "codex", + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-append"), + }); + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-buffered-request-append", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-buffered-request-append-initial"), + provider: "codex", + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-append"), + itemId: asItemId("item-buffered-request-append"), + payload: { + streamKind: "assistant_text", + delta: "first half", + }, + }); + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened-buffered-request-append"), + provider: "codex", + createdAt: pausedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-append"), + requestId: ApprovalRequestId.makeUnsafe("req-buffered-request-append"), + payload: { + requestType: "command_execution_approval", + detail: "pwd", + }, + }); + + await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered-request-append" && + !message.streaming && + message.text === "first half", + ), + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-buffered-request-append-followup"), + provider: "codex", + createdAt: resumedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-append"), + itemId: asItemId("item-buffered-request-append"), + payload: { + streamKind: "assistant_text", + delta: " second half", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-buffered-request-append"), + provider: "codex", + createdAt: completedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-buffered-request-append"), + itemId: asItemId("item-buffered-request-append"), + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-buffered-request-append:segment:1" && + !message.streaming && + message.text === " second half", + ), + ); + const firstMessage = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered-request-append", + ); + const resumedMessage = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => + entry.id === "assistant:item-buffered-request-append:segment:1", + ); + expect(firstMessage?.text).toBe("first half"); + expect(firstMessage?.streaming).toBe(false); + expect(resumedMessage?.text).toBe(" second half"); + expect(resumedMessage?.streaming).toBe(false); + + const events = await Effect.runPromise( + Stream.runCollect(harness.engine.readEvents(0)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ), + ); + const assistantEvents = events.filter( + (event): event is Extract<(typeof events)[number], { type: "thread.message-sent" }> => + event.type === "thread.message-sent" && + event.payload.messageId.startsWith("assistant:item-buffered-request-append"), + ); + expect(assistantEvents).toHaveLength(4); + expect(assistantEvents[0]?.payload.streaming).toBe(true); + expect(assistantEvents[0]?.payload.text).toBe("first half"); + expect(assistantEvents[1]?.payload.streaming).toBe(false); + expect(assistantEvents[1]?.payload.text).toBe(""); + expect(assistantEvents[2]?.payload.messageId).toBe( + "assistant:item-buffered-request-append:segment:1", + ); + expect(assistantEvents[2]?.payload.streaming).toBe(true); + expect(assistantEvents[2]?.payload.text).toBe(" second half"); + expect(assistantEvents[3]?.payload.messageId).toBe( + "assistant:item-buffered-request-append:segment:1", + ); + expect(assistantEvents[3]?.payload.streaming).toBe(false); + expect(assistantEvents[3]?.payload.text).toBe(""); + }); + + it("starts a new streaming assistant message segment after approval", async () => { + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const startedAt = "2026-03-28T07:00:00.000Z"; + const pausedAt = "2026-03-28T07:00:01.000Z"; + const resumedAt = "2026-03-28T07:00:02.000Z"; + const completedAt = "2026-03-28T07:00:03.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-streaming-request-segment"), + provider: "codex", + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-request-segment"), + }); + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-streaming-request-segment", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-streaming-request-segment-initial"), + provider: "codex", + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-request-segment"), + itemId: asItemId("item-streaming-request-segment"), + payload: { + streamKind: "assistant_text", + delta: "before approval", + }, + }); + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened-streaming-request-segment"), + provider: "codex", + createdAt: pausedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-request-segment"), + requestId: ApprovalRequestId.makeUnsafe("req-streaming-request-segment"), + payload: { + requestType: "command_execution_approval", + detail: "pwd", + }, + }); + + await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-request-segment" && + !message.streaming && + message.text === "before approval", + ), + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-streaming-request-segment-followup"), + provider: "codex", + createdAt: resumedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-request-segment"), + itemId: asItemId("item-streaming-request-segment"), + payload: { + streamKind: "assistant_text", + delta: " after approval", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-streaming-request-segment"), + provider: "codex", + createdAt: completedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-request-segment"), + itemId: asItemId("item-streaming-request-segment"), + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-request-segment:segment:1" && + !message.streaming && + message.text === " after approval", + ), + ); + expect( + thread.messages.find( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-request-segment", + )?.text, + ).toBe("before approval"); + expect( + thread.messages.find( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-request-segment:segment:1", + )?.text, + ).toBe(" after approval"); + }); + it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index c7c4daf2512..44dac156d0c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -32,6 +32,12 @@ const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${t const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); +interface AssistantSegmentState { + baseKey: string; + nextSegmentIndex: number; + activeMessageId: MessageId | null; +} + const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; @@ -83,6 +89,10 @@ function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string return trimmed; } +function hasRenderableAssistantText(text: string | undefined): boolean { + return (text?.trim().length ?? 0) > 0; +} + function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { return `plan:${threadId}:turn:${turnId}`; } @@ -98,6 +108,16 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId return `plan:${threadId}:event:${event.eventId}`; } +function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string { + return String(event.itemId ?? event.turnId ?? event.eventId); +} + +function assistantSegmentMessageId(baseKey: string, segmentIndex: number): MessageId { + return MessageId.makeUnsafe( + segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`, + ); +} + function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -552,6 +572,15 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed(""), }); + const assistantSegmentStateByTurnKey = yield* Cache.make({ + capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, + timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL, + lookup: () => + Effect.die( + new Error("assistant segment state should be read through getOption before initialization"), + ), + }); + const bufferedProposedPlanById = yield* Cache.make({ capacity: BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY, timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL, @@ -619,6 +648,82 @@ const make = Effect.gen(function* () { const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) => Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)); + const getAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) => + Cache.getOption(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId)); + + const setAssistantSegmentStateForTurn = ( + threadId: ThreadId, + turnId: TurnId, + state: AssistantSegmentState, + ) => Cache.set(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId), state); + + const clearAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) => + Cache.invalidate(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId)); + + const getActiveAssistantMessageIdForTurn = (threadId: ThreadId, turnId: TurnId) => + getAssistantSegmentStateForTurn(threadId, turnId).pipe( + Effect.map((state) => + Option.flatMap(state, (entry) => + entry.activeMessageId ? Option.some(entry.activeMessageId) : Option.none(), + ), + ), + ); + + const startAssistantSegmentForTurn = (input: { + threadId: ThreadId; + turnId: TurnId; + baseKey: string; + }) => + getAssistantSegmentStateForTurn(input.threadId, input.turnId).pipe( + Effect.flatMap((existingState) => + Effect.gen(function* () { + const nextState = Option.match(existingState, { + onNone: () => ({ + baseKey: input.baseKey, + nextSegmentIndex: 1, + activeMessageId: assistantSegmentMessageId(input.baseKey, 0), + }), + onSome: (state) => { + const segmentIndex = state.baseKey === input.baseKey ? state.nextSegmentIndex : 0; + const messageId = assistantSegmentMessageId(input.baseKey, segmentIndex); + return { + baseKey: input.baseKey, + nextSegmentIndex: state.baseKey === input.baseKey ? state.nextSegmentIndex + 1 : 1, + activeMessageId: messageId, + } satisfies AssistantSegmentState; + }, + }); + yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, nextState); + return nextState.activeMessageId!; + }), + ), + ); + + const getOrCreateAssistantMessageId = (input: { + threadId: ThreadId; + event: ProviderRuntimeEvent; + turnId?: TurnId; + }) => + Effect.gen(function* () { + if (!input.turnId) { + return assistantSegmentMessageId(assistantSegmentBaseKeyFromEvent(input.event), 0); + } + + const activeMessageId = yield* getActiveAssistantMessageIdForTurn( + input.threadId, + input.turnId, + ); + if (Option.isSome(activeMessageId)) { + return activeMessageId.value; + } + + return yield* startAssistantSegmentForTurn({ + threadId: input.threadId, + turnId: input.turnId, + baseKey: assistantSegmentBaseKeyFromEvent(input.event), + }); + }); + const appendBufferedAssistantText = (messageId: MessageId, delta: string) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( Effect.flatMap((existingText) => @@ -678,6 +783,65 @@ const make = Effect.gen(function* () { const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); + const flushBufferedAssistantMessage = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + messageId: MessageId; + turnId?: TurnId; + createdAt: string; + commandTag: string; + }) => + Effect.gen(function* () { + const bufferedText = yield* takeBufferedAssistantText(input.messageId); + if (!hasRenderableAssistantText(bufferedText)) { + return false; + } + + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.delta", + commandId: providerCommandId(input.event, input.commandTag), + threadId: input.threadId, + messageId: input.messageId, + delta: bufferedText, + ...(input.turnId ? { turnId: input.turnId } : {}), + createdAt: input.createdAt, + }); + return true; + }); + + const flushBufferedAssistantMessagesForTurn = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + turnId: TurnId; + createdAt: string; + commandTag: string; + }) => + Effect.gen(function* () { + const assistantMessageIds = yield* getAssistantMessageIdsForTurn( + input.threadId, + input.turnId, + ); + const flushedMessageIds = new Set(); + yield* Effect.forEach( + assistantMessageIds, + (messageId) => + flushBufferedAssistantMessage({ + event: input.event, + threadId: input.threadId, + messageId, + turnId: input.turnId, + createdAt: input.createdAt, + commandTag: input.commandTag, + }).pipe( + Effect.tap((flushed) => + flushed ? Effect.sync(() => flushedMessageIds.add(messageId)) : Effect.void, + ), + ), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + return flushedMessageIds; + }); + const finalizeAssistantMessage = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; @@ -687,6 +851,7 @@ const make = Effect.gen(function* () { commandTag: string; finalDeltaCommandTag: string; fallbackText?: string; + hasProjectedMessage?: boolean; }) => Effect.gen(function* () { const bufferedText = yield* takeBufferedAssistantText(input.messageId); @@ -696,8 +861,9 @@ const make = Effect.gen(function* () { : (input.fallbackText?.trim().length ?? 0) > 0 ? input.fallbackText! : ""; + const hasRenderableText = hasRenderableAssistantText(text); - if (text.length > 0) { + if (hasRenderableText) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", commandId: providerCommandId(input.event, input.finalDeltaCommandTag), @@ -709,15 +875,59 @@ const make = Effect.gen(function* () { }); } - yield* orchestrationEngine.dispatch({ - type: "thread.message.assistant.complete", - commandId: providerCommandId(input.event, input.commandTag), + if (input.hasProjectedMessage || hasRenderableText) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.complete", + commandId: providerCommandId(input.event, input.commandTag), + threadId: input.threadId, + messageId: input.messageId, + ...(input.turnId ? { turnId: input.turnId } : {}), + createdAt: input.createdAt, + }); + } + yield* clearAssistantMessageState(input.messageId); + }); + + const finalizeActiveAssistantSegmentForTurn = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + turnId: TurnId; + createdAt: string; + commandTag: string; + finalDeltaCommandTag: string; + hasProjectedMessage: boolean; + flushedMessageIds?: ReadonlySet; + }) => + Effect.gen(function* () { + const activeMessageId = yield* getActiveAssistantMessageIdForTurn( + input.threadId, + input.turnId, + ); + if (Option.isNone(activeMessageId)) { + return; + } + + yield* finalizeAssistantMessage({ + event: input.event, threadId: input.threadId, - messageId: input.messageId, - ...(input.turnId ? { turnId: input.turnId } : {}), + messageId: activeMessageId.value, + turnId: input.turnId, createdAt: input.createdAt, + commandTag: input.commandTag, + finalDeltaCommandTag: input.finalDeltaCommandTag, + hasProjectedMessage: + input.hasProjectedMessage || + (input.flushedMessageIds?.has(activeMessageId.value) ?? false), }); - yield* clearAssistantMessageState(input.messageId); + yield* forgetAssistantMessageId(input.threadId, input.turnId, activeMessageId.value); + + const state = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId); + if (Option.isSome(state)) { + yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, { + ...state.value, + activeMessageId: null, + }); + } }); const upsertProposedPlan = (input: { @@ -803,6 +1013,7 @@ const make = Effect.gen(function* () { const prefix = `${threadId}:`; const proposedPlanPrefix = `plan:${threadId}:`; const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); + const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey)); const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); yield* Effect.forEach( turnKeys, @@ -823,6 +1034,14 @@ const make = Effect.gen(function* () { }), { concurrency: 1 }, ).pipe(Effect.asVoid); + yield* Effect.forEach( + assistantSegmentKeys, + (key) => + key.startsWith(prefix) + ? Cache.invalidate(assistantSegmentStateByTurnKey, key) + : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); yield* Effect.forEach( proposedPlanKeys, (key) => @@ -1038,10 +1257,12 @@ const make = Effect.gen(function* () { event.type === "turn.proposed.delta" ? event.payload.delta : undefined; if (assistantDelta && assistantDelta.length > 0) { - const assistantMessageId = MessageId.makeUnsafe( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ); const turnId = toTurnId(event.turnId); + const assistantMessageId = yield* getOrCreateAssistantMessageId({ + threadId: thread.id, + event, + ...(turnId ? { turnId } : {}), + }); if (turnId) { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } @@ -1076,6 +1297,49 @@ const make = Effect.gen(function* () { } } + const pauseForUserTurnId = + event.type === "request.opened" || event.type === "user-input.requested" + ? toTurnId(event.turnId) + : undefined; + if (pauseForUserTurnId) { + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + ); + const flushedMessageIds = + assistantDeliveryMode === "buffered" + ? yield* flushBufferedAssistantMessagesForTurn({ + event, + threadId: thread.id, + turnId: pauseForUserTurnId, + createdAt: now, + commandTag: + event.type === "request.opened" + ? "assistant-delta-flush-on-request-opened" + : "assistant-delta-flush-on-user-input-requested", + }) + : new Set(); + yield* finalizeActiveAssistantSegmentForTurn({ + event, + threadId: thread.id, + turnId: pauseForUserTurnId, + createdAt: now, + commandTag: + event.type === "request.opened" + ? "assistant-complete-on-request-opened" + : "assistant-complete-on-user-input-requested", + finalDeltaCommandTag: + event.type === "request.opened" + ? "assistant-delta-finalize-on-request-opened" + : "assistant-delta-finalize-on-user-input-requested", + hasProjectedMessage: thread.messages.some( + (entry) => + entry.role === "assistant" && entry.turnId === pauseForUserTurnId && entry.streaming, + ), + flushedMessageIds, + }); + } + if (proposedPlanDelta && proposedPlanDelta.length > 0) { const planId = proposedPlanIdFromEvent(event, thread.id); yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now); @@ -1100,32 +1364,56 @@ const make = Effect.gen(function* () { : undefined; if (assistantCompletion) { - const assistantMessageId = assistantCompletion.messageId; const turnId = toTurnId(event.turnId); + const activeAssistantMessageId = turnId + ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) + : Option.none(); + const hasAssistantMessagesForTurn = + turnId !== undefined + ? thread.messages.some((entry) => entry.role === "assistant" && entry.turnId === turnId) + : false; + const assistantMessageId = Option.getOrElse( + activeAssistantMessageId, + () => assistantCompletion.messageId, + ); const existingAssistantMessage = thread.messages.find( (entry) => entry.id === assistantMessageId, ); const shouldApplyFallbackCompletionText = !existingAssistantMessage || existingAssistantMessage.text.length === 0; - if (turnId) { - yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); - } - yield* finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - ...(turnId ? { turnId } : {}), - createdAt: now, - commandTag: "assistant-complete", - finalDeltaCommandTag: "assistant-delta-finalize", - ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText - ? { fallbackText: assistantCompletion.fallbackText } - : {}), - }); + const shouldSkipRedundantCompletion = + Option.isNone(activeAssistantMessageId) && + turnId !== undefined && + hasAssistantMessagesForTurn && + (assistantCompletion.fallbackText?.trim().length ?? 0) === 0; + + if (!shouldSkipRedundantCompletion) { + if (turnId && Option.isNone(activeAssistantMessageId)) { + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); + } + + yield* finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId: assistantMessageId, + ...(turnId ? { turnId } : {}), + createdAt: now, + commandTag: "assistant-complete", + finalDeltaCommandTag: "assistant-delta-finalize", + hasProjectedMessage: existingAssistantMessage !== undefined, + ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText + ? { fallbackText: assistantCompletion.fallbackText } + : {}), + }); + + if (turnId) { + yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); + } + } if (turnId) { - yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); + yield* clearAssistantSegmentStateForTurn(thread.id, turnId); } } @@ -1156,10 +1444,14 @@ const make = Effect.gen(function* () { createdAt: now, commandTag: "assistant-complete-finalize", finalDeltaCommandTag: "assistant-delta-finalize-fallback", + hasProjectedMessage: thread.messages.some( + (entry) => entry.id === assistantMessageId, + ), }), { concurrency: 1 }, ).pipe(Effect.asVoid); yield* clearAssistantMessageIdsForTurn(thread.id, turnId); + yield* clearAssistantSegmentStateForTurn(thread.id, turnId); yield* finalizeBufferedProposedPlan({ event, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 2ee9c7a8ea9..f5761b27591 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -95,7 +95,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( Stream.runCollect, Effect.forkChild, ); @@ -129,18 +129,32 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { "thread.started", "turn.started", "turn.plan.updated", + "item.started", "content.delta", + "item.completed", "turn.completed", ] as const) { assert.include(types, t); } + const assistantStarted = runtimeEvents.find( + (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", + ); + assert.isDefined(assistantStarted); + const delta = runtimeEvents.find((e) => e.type === "content.delta"); assert.isDefined(delta); if (delta?.type === "content.delta") { assert.equal(delta.payload.delta, "hello from mock"); + assert.match(String(delta.itemId), /^assistant:mock-session-1:segment:0$/); } + const assistantCompleted = runtimeEvents.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "assistant_message", + ); + assert.isDefined(assistantCompleted); + const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated"); assert.isDefined(planUpdate); if (planUpdate?.type === "turn.plan.updated") { @@ -201,59 +215,228 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.stopSession(threadId); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const modeRequest = requests.find((entry) => entry.method === "session/set_mode"); + const modeRequest = requests.find( + (entry) => + entry.method === "session/set_mode" || + (entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "mode"), + ); assert.isDefined(modeRequest); - assert.deepStrictEqual(modeRequest?.params, { - sessionId: "mock-session-1", - modeId: "architect", - }); + assert.equal( + (modeRequest?.params as Record | undefined)?.sessionId, + "mock-session-1", + ); + assert.include( + ["architect", "plan"], + String( + (modeRequest?.params as Record | undefined)?.modeId ?? + (modeRequest?.params as Record | undefined)?.value, + ), + ); }), ); - it.effect("streams ACP tool calls and approvals on the active turn in real time", () => - Effect.gen(function* () { - const previousEmitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS; - process.env.T3_ACP_EMIT_TOOL_CALLS = "1"; + it.effect( + "streams ACP tool calls and approvals on the active turn in approval-required mode", + () => + Effect.gen(function* () { + const previousEmitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS; + process.env.T3_ACP_EMIT_TOOL_CALLS = "1"; + + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-tool-call-probe"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, + }); - const adapter = yield* CursorAdapter; - const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.makeUnsafe("cursor-tool-call-probe"); - const runtimeEvents: Array = []; - const settledEventTypes = new Set(); - const settledEventsReady = yield* Deferred.make(); + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "request.opened" && event.requestId) { + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(String(event.requestId)), + "accept", + ); + } + if ( + event.type === "turn.completed" || + (event.type === "item.completed" && event.payload.itemType === "command_execution") || + event.type === "content.delta" + ) { + settledEventTypes.add(event.type); + if (settledEventTypes.size === 3) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); + + const program = Effect.gen(function* () { + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { provider: "cursor", model: "default" }, + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run a tool call", + attachments: [], + }); + yield* Deferred.await(settledEventsReady); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + assert.includeMembers( + threadEvents.map((event) => event.type), + [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "request.opened", + "request.resolved", + "item.updated", + "item.completed", + "content.delta", + "turn.completed", + ], + ); + + const turnEvents = threadEvents.filter( + (event) => String(event.turnId) === String(turn.turnId), + ); + const toolUpdates = turnEvents.filter((event) => event.type === "item.updated"); + assert.lengthOf(toolUpdates, 2); + for (const toolUpdate of toolUpdates) { + if (toolUpdate.type !== "item.updated") { + continue; + } + assert.equal(toolUpdate.payload.itemType, "command_execution"); + assert.equal(toolUpdate.payload.status, "inProgress"); + assert.equal(toolUpdate.payload.detail, "cat server/package.json"); + assert.equal(String(toolUpdate.itemId), "tool-call-1"); + } - const wrapperPath = yield* Effect.promise(() => - makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), - ); - yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + const requestOpened = turnEvents.find((event) => event.type === "request.opened"); + assert.isDefined(requestOpened); + if (requestOpened?.type === "request.opened") { + assert.equal(String(requestOpened.turnId), String(turn.turnId)); + assert.equal(requestOpened.payload.requestType, "exec_command_approval"); + assert.equal(requestOpened.payload.detail, "cat server/package.json"); + } - yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.gen(function* () { - runtimeEvents.push(event); - if (String(event.threadId) !== String(threadId)) { - return; + const requestResolved = turnEvents.find((event) => event.type === "request.resolved"); + assert.isDefined(requestResolved); + if (requestResolved?.type === "request.resolved") { + assert.equal(String(requestResolved.turnId), String(turn.turnId)); + assert.equal(requestResolved.payload.requestType, "exec_command_approval"); + assert.equal(requestResolved.payload.decision, "accept"); } - if (event.type === "request.opened" && event.requestId) { - yield* adapter.respondToRequest( - threadId, - ApprovalRequestId.makeUnsafe(String(event.requestId)), - "accept", - ); + + const toolCompleted = turnEvents.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + assert.isDefined(toolCompleted); + if (toolCompleted?.type === "item.completed") { + assert.equal(String(toolCompleted.turnId), String(turn.turnId)); + assert.equal(toolCompleted.payload.itemType, "command_execution"); + assert.equal(toolCompleted.payload.status, "completed"); + assert.equal(toolCompleted.payload.detail, "cat server/package.json"); + assert.equal(String(toolCompleted.itemId), "tool-call-1"); } - if ( - event.type === "turn.completed" || - event.type === "item.completed" || - event.type === "content.delta" - ) { - settledEventTypes.add(event.type); - if (settledEventTypes.size === 3) { - yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); - } + + const contentDelta = turnEvents.find((event) => event.type === "content.delta"); + assert.isDefined(contentDelta); + if (contentDelta?.type === "content.delta") { + assert.equal(String(contentDelta.turnId), String(turn.turnId)); + assert.equal(contentDelta.payload.delta, "hello from mock"); + assert.equal(String(contentDelta.itemId), "assistant:mock-session-1:segment:0"); } - }), - ).pipe(Effect.forkChild); + }); + + yield* program.pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousEmitToolCalls === undefined) { + delete process.env.T3_ACP_EMIT_TOOL_CALLS; + } else { + process.env.T3_ACP_EMIT_TOOL_CALLS = previousEmitToolCalls; + } + }), + ), + ); + }).pipe( + Effect.provide( + makeCursorAdapterLive().pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-adapter-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + it.effect( + "auto-approves ACP tool permissions in full-access mode without approval runtime events", + () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-full-access-auto-approve"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, + }); + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if ( + event.type === "turn.completed" || + (event.type === "item.completed" && event.payload.itemType === "command_execution") || + event.type === "content.delta" + ) { + settledEventTypes.add(event.type); + if (settledEventTypes.size === 3) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); - const program = Effect.gen(function* () { yield* adapter.startSession({ threadId, provider: "cursor", @@ -267,100 +450,174 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { input: "run a tool call", attachments: [], }); + yield* Deferred.await(settledEventsReady); + yield* Fiber.interrupt(runtimeEventsFiber); - const threadEvents = runtimeEvents.filter( - (event) => String(event.threadId) === String(threadId), + const turnEvents = runtimeEvents.filter( + (event) => + String(event.threadId) === String(threadId) && + String(event.turnId) === String(turn.turnId), + ); + assert.notInclude( + turnEvents.map((event) => event.type), + "request.opened", + ); + assert.notInclude( + turnEvents.map((event) => event.type), + "request.resolved", ); assert.includeMembers( - threadEvents.map((event) => event.type), - [ - "session.started", - "session.state.changed", - "thread.started", - "turn.started", - "request.opened", - "request.resolved", - "item.updated", - "item.completed", - "content.delta", - "turn.completed", - ], + turnEvents.map((event) => event.type), + ["item.updated", "item.completed", "content.delta", "turn.completed"], ); - const turnEvents = threadEvents.filter( - (event) => String(event.turnId) === String(turn.turnId), + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const permissionResponse = requests.find( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "outcome" in entry.result.outcome && + entry.result.outcome.outcome === "selected" && + "optionId" in entry.result.outcome && + entry.result.outcome.optionId === "allow-always", ); - const toolUpdates = turnEvents.filter((event) => event.type === "item.updated"); - assert.lengthOf(toolUpdates, 2); - for (const toolUpdate of toolUpdates) { - if (toolUpdate.type !== "item.updated") { - continue; - } - assert.equal(toolUpdate.payload.itemType, "command_execution"); - assert.equal(toolUpdate.payload.status, "inProgress"); - assert.equal(toolUpdate.payload.detail, "cat server/package.json"); - assert.equal(String(toolUpdate.itemId), "tool-call-1"); - } - - const requestOpened = turnEvents.find((event) => event.type === "request.opened"); - assert.isDefined(requestOpened); - if (requestOpened?.type === "request.opened") { - assert.equal(String(requestOpened.turnId), String(turn.turnId)); - assert.equal(requestOpened.payload.requestType, "exec_command_approval"); - assert.equal(requestOpened.payload.detail, "cat server/package.json"); - } + assert.isDefined(permissionResponse); - const requestResolved = turnEvents.find((event) => event.type === "request.resolved"); - assert.isDefined(requestResolved); - if (requestResolved?.type === "request.resolved") { - assert.equal(String(requestResolved.turnId), String(turn.turnId)); - assert.equal(requestResolved.payload.requestType, "exec_command_approval"); - assert.equal(requestResolved.payload.decision, "accept"); - } + yield* adapter.stopSession(threadId); + }), + ); - const toolCompleted = turnEvents.find((event) => event.type === "item.completed"); - assert.isDefined(toolCompleted); - if (toolCompleted?.type === "item.completed") { - assert.equal(String(toolCompleted.turnId), String(turn.turnId)); - assert.equal(toolCompleted.payload.itemType, "command_execution"); - assert.equal(toolCompleted.payload.status, "completed"); - assert.equal(toolCompleted.payload.detail, "cat server/package.json"); - assert.equal(String(toolCompleted.itemId), "tool-call-1"); - } + it.effect("segments assistant messages around ACP tool activity in full-access mode", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.makeUnsafe("cursor-assistant-tool-segmentation"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); - const contentDelta = turnEvents.find((event) => event.type === "content.delta"); - assert.isDefined(contentDelta); - if (contentDelta?.type === "content.delta") { - assert.equal(String(contentDelta.turnId), String(turn.turnId)); - assert.equal(contentDelta.payload.delta, "hello from mock"); - } + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1" }), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, }); - yield* program.pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousEmitToolCalls === undefined) { - delete process.env.T3_ACP_EMIT_TOOL_CALLS; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if ( + event.type === "content.delta" || + (event.type === "item.completed" && event.payload.itemType === "command_execution") || + event.type === "turn.completed" + ) { + if (event.type === "content.delta") { + settledEventTypes.add(`delta:${event.payload.delta}`); } else { - process.env.T3_ACP_EMIT_TOOL_CALLS = previousEmitToolCalls; + settledEventTypes.add(event.type); } - }), - ), + if ( + settledEventTypes.has("delta:before tool") && + settledEventTypes.has("delta:after tool") && + settledEventTypes.has("item.completed") && + settledEventTypes.has("turn.completed") + ) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run an interleaved tool call", + attachments: [], + }); + + yield* Deferred.await(settledEventsReady); + yield* Fiber.interrupt(runtimeEventsFiber); + + const turnEvents = runtimeEvents.filter( + (event) => + String(event.threadId) === String(threadId) && + String(event.turnId) === String(turn.turnId), ); - }).pipe( - Effect.provide( - makeCursorAdapterLive().pipe( - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-cursor-adapter-test-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ), - ), + const firstAssistantStartIndex = turnEvents.findIndex( + (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", + ); + const firstAssistantDeltaIndex = turnEvents.findIndex( + (event) => event.type === "content.delta" && event.payload.delta === "before tool", + ); + const assistantBoundaryIndex = turnEvents.findIndex( + (event) => + event.type === "item.completed" && event.payload.itemType === "assistant_message", + ); + const toolUpdateIndex = turnEvents.findIndex( + (event) => event.type === "item.updated" && event.payload.itemType === "command_execution", + ); + const toolCompletedIndex = turnEvents.findIndex( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + const secondAssistantStartIndex = turnEvents.findIndex( + (event, index) => + index > toolCompletedIndex && + event.type === "item.started" && + event.payload.itemType === "assistant_message", + ); + const secondAssistantDeltaIndex = turnEvents.findIndex( + (event) => event.type === "content.delta" && event.payload.delta === "after tool", + ); + + assert.isAtLeast(firstAssistantStartIndex, 0); + assert.isAtLeast(firstAssistantDeltaIndex, 0); + assert.isAtLeast(assistantBoundaryIndex, 0); + assert.isAtLeast(toolUpdateIndex, 0); + assert.isAtLeast(toolCompletedIndex, 0); + assert.isAtLeast(secondAssistantStartIndex, 0); + assert.isAtLeast(secondAssistantDeltaIndex, 0); + assert.isBelow(firstAssistantStartIndex, firstAssistantDeltaIndex); + assert.isBelow(firstAssistantDeltaIndex, assistantBoundaryIndex); + assert.isBelow(assistantBoundaryIndex, toolUpdateIndex); + assert.isBelow(toolUpdateIndex, toolCompletedIndex); + assert.isBelow(toolCompletedIndex, secondAssistantStartIndex); + assert.isBelow(secondAssistantStartIndex, secondAssistantDeltaIndex); + + const assistantStarts = turnEvents.filter( + (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", + ); + const assistantDeltas = turnEvents.filter((event) => event.type === "content.delta"); + assert.lengthOf(assistantStarts, 2); + assert.lengthOf(assistantDeltas, 2); + if ( + assistantStarts[0]?.type === "item.started" && + assistantStarts[1]?.type === "item.started" && + assistantDeltas[0]?.type === "content.delta" && + assistantDeltas[1]?.type === "content.delta" + ) { + assert.notEqual(String(assistantStarts[0].itemId), String(assistantStarts[1].itemId)); + assert.equal(String(assistantDeltas[0].itemId), String(assistantStarts[0].itemId)); + assert.equal(String(assistantDeltas[1].itemId), String(assistantStarts[1].itemId)); + } + + yield* adapter.stopSession(threadId); + }), ); it.effect("cancels pending ACP approvals and marks the turn cancelled when interrupted", () => @@ -405,7 +662,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, provider: "cursor", cwd: process.cwd(), - runtimeMode: "full-access", + runtimeMode: "approval-required", modelSelection: { provider: "cursor", model: "default" }, }); @@ -475,7 +732,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, provider: "cursor", cwd: process.cwd(), - runtimeMode: "full-access", + runtimeMode: "approval-required", modelSelection: { provider: "cursor", model: "default" }, }); @@ -579,7 +836,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const setConfigRequests = requests.filter( - (entry) => entry.method === "session/set_config_option", + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "model", ); assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); assert.equal((setConfigRequests[0]?.params as Record)?.value, "composer-2"); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 0d39fed5825..5914d1abd06 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -45,6 +45,7 @@ import { import { AcpSessionRuntime, type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; import { + makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, makeAcpPlanUpdatedEvent, makeAcpRequestOpenedEvent, @@ -207,6 +208,22 @@ function resolveRequestedModeId(input: { ); } +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -496,6 +513,17 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { yield* acp.handleRequestPermission((params) => Effect.gen(function* () { yield* logNative(input.threadId, "session/request_permission", params, "acp.jsonrpc"); + if (ctx?.session.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } const permissionRequest = parsePermissionRequest(params); const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); @@ -585,6 +613,30 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { switch (event._tag) { case "ModeChanged": return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; case "PlanUpdated": yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); yield* emitPlanUpdate( @@ -616,6 +668,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), text: event.text, rawPayload: event.rawPayload, }), @@ -895,7 +948,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { listSessions, hasSession, stopAll, - streamEvents: Stream.fromQueue(runtimeEventQueue), + get streamEvents() { + return Stream.fromQueue(runtimeEventQueue); + }, } satisfies CursorAdapterShape; }); } diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts index f40baf4143a..4689e64fa2a 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -2,6 +2,7 @@ import { RuntimeRequestId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { + makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, makeAcpPlanUpdatedEvent, makeAcpRequestOpenedEvent, @@ -121,14 +122,34 @@ describe("AcpCoreRuntimeEvents", () => { provider: "cursor", threadId: "thread-1" as never, turnId, + itemId: "assistant:session-1:segment:0", text: "hello", rawPayload: { sessionId: "session-1" }, }), ).toMatchObject({ type: "content.delta", + itemId: "assistant:session-1:segment:0", payload: { delta: "hello", }, }); + + expect( + makeAcpAssistantItemEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId, + itemId: "assistant:session-1:segment:0", + lifecycle: "item.started", + }), + ).toMatchObject({ + type: "item.started", + itemId: "assistant:session-1:segment:0", + payload: { + itemType: "assistant_message", + status: "inProgress", + }, + }); }); }); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index 2d65dc74f01..bd0c27c7aba 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -191,11 +191,34 @@ export function makeAcpToolCallEvent(input: { }; } +export function makeAcpAssistantItemEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly itemId: string; + readonly lifecycle: "item.started" | "item.completed"; +}): ProviderRuntimeEvent { + return { + type: input.lifecycle, + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + itemId: RuntimeItemId.makeUnsafe(input.itemId), + payload: { + itemType: "assistant_message", + status: input.lifecycle === "item.completed" ? "completed" : "inProgress", + }, + }; +} + export function makeAcpContentDeltaEvent(input: { readonly stamp: AcpEventStamp; readonly provider: ProviderKind; readonly threadId: ThreadId; readonly turnId: TurnId | undefined; + readonly itemId?: string; readonly text: string; readonly rawPayload: unknown; }): ProviderRuntimeEvent { @@ -205,6 +228,7 @@ export function makeAcpContentDeltaEvent(input: { provider: input.provider, threadId: input.threadId, turnId: input.turnId, + ...(input.itemId ? { itemId: RuntimeItemId.makeUnsafe(input.itemId) } : {}), payload: { streamKind: "assistant_text", delta: input.text, diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 1c66d399ed0..bf3c4923732 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -29,14 +29,127 @@ describe("AcpSessionRuntime", () => { }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.events, 2))); - expect(notes).toHaveLength(2); - expect(notes.map((note) => note._tag)).toEqual(["PlanUpdated", "ContentDelta"]); + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.events, 4))); + expect(notes).toHaveLength(4); + expect(notes.map((note) => note._tag)).toEqual([ + "PlanUpdated", + "AssistantItemStarted", + "ContentDelta", + "AssistantItemCompleted", + ]); const planUpdate = notes.find((note) => note._tag === "PlanUpdated"); expect(planUpdate?._tag).toBe("PlanUpdated"); if (planUpdate?._tag === "PlanUpdated") { expect(planUpdate.payload.plan).toHaveLength(2); } + const assistantStart = notes[1]; + const assistantDelta = notes[2]; + if ( + assistantStart?._tag === "AssistantItemStarted" && + assistantDelta?._tag === "ContentDelta" + ) { + expect(assistantDelta.itemId).toBe(assistantStart.itemId); + } + + yield* runtime.close; + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("segments assistant text around ACP tool calls", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + const promptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + expect(promptResult).toMatchObject({ stopReason: "end_turn" }); + + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.events, 7))); + expect(notes.map((note) => note._tag)).toEqual([ + "AssistantItemStarted", + "ContentDelta", + "AssistantItemCompleted", + "ToolCallUpdated", + "ToolCallUpdated", + "AssistantItemStarted", + "ContentDelta", + ]); + + const firstStarted = notes[0]; + const firstDelta = notes[1]; + const firstCompleted = notes[2]; + const secondStarted = notes[5]; + const secondDelta = notes[6]; + expect(firstStarted?._tag).toBe("AssistantItemStarted"); + expect(firstCompleted?._tag).toBe("AssistantItemCompleted"); + expect(secondStarted?._tag).toBe("AssistantItemStarted"); + if ( + firstStarted?._tag === "AssistantItemStarted" && + firstDelta?._tag === "ContentDelta" && + firstCompleted?._tag === "AssistantItemCompleted" && + secondStarted?._tag === "AssistantItemStarted" && + secondDelta?._tag === "ContentDelta" + ) { + expect(firstDelta.itemId).toBe(firstStarted.itemId); + expect(firstCompleted.itemId).toBe(firstStarted.itemId); + expect(secondStarted.itemId).not.toBe(firstStarted.itemId); + expect(secondDelta.itemId).toBe(secondStarted.itemId); + } + + yield* runtime.close; + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: bunExe, + args: [mockAgentPath], + env: { + T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("suppresses generic placeholder tool updates until completion", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + const promptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + expect(promptResult).toMatchObject({ stopReason: "end_turn" }); + + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.events, 1))); + expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]); + const toolCall = notes[0]; + expect(toolCall?._tag).toBe("ToolCallUpdated"); + if (toolCall?._tag === "ToolCallUpdated") { + expect(toolCall.toolCall.status).toBe("completed"); + expect(toolCall.toolCall.title).toBe("Read file"); + } yield* runtime.close; }).pipe( @@ -45,6 +158,9 @@ describe("AcpSessionRuntime", () => { spawn: { command: bunExe, args: [mockAgentPath], + env: { + T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1", + }, }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 5d1cc5eca52..27826a3571b 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -90,7 +90,7 @@ describe("AcpRuntimeModel", () => { toolCall: { toolCallId: "tool-1", kind: "execute", - title: "Terminal", + title: "Ran command", status: "pending", command: "bun run typecheck", detail: "bun run typecheck", @@ -157,6 +157,8 @@ describe("AcpRuntimeModel", () => { expect(mergeToolCallState(createdEvent.toolCall, updatedEvent.toolCall)).toMatchObject({ toolCallId: "tool-1", status: "completed", + title: "Ran command", + detail: "bun run typecheck", command: "bun run typecheck", }); } diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 950fa64e873..21f26f8e757 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -1,4 +1,6 @@ import type * as EffectAcpSchema from "effect-acp/schema"; +import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity"; +import type { ToolLifecycleItemType } from "@t3tools/contracts"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -44,6 +46,14 @@ export type AcpParsedSessionEvent = readonly _tag: "ModeChanged"; readonly modeId: string; } + | { + readonly _tag: "AssistantItemStarted"; + readonly itemId: string; + } + | { + readonly _tag: "AssistantItemCompleted"; + readonly itemId: string; + } | { readonly _tag: "PlanUpdated"; readonly payload: AcpPlanUpdate; @@ -56,6 +66,7 @@ export type AcpParsedSessionEvent = } | { readonly _tag: "ContentDelta"; + readonly itemId?: string; readonly text: string; readonly rawPayload: unknown; }; @@ -230,6 +241,22 @@ function normalizeToolKind(kind: unknown): string | undefined { return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; } +function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { + switch (kind) { + case "execute": + return "command_execution"; + case "edit": + case "delete": + case "move": + return "file_change"; + case "search": + case "fetch": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + function makeToolCallState( input: { readonly toolCallId: string; @@ -256,7 +283,6 @@ function makeToolCallState( title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" ? title : undefined; - const detail = command ?? normalizedTitle ?? textContent; const data: Record = { toolCallId }; const kind = normalizeToolKind(input.kind); if (kind) { @@ -277,14 +303,30 @@ function makeToolCallState( if (input.locations !== undefined) { data.locations = input.locations; } + const fallbackDetail = command ?? normalizedTitle ?? textContent; + const hasPresentationSeed = + title !== undefined || + kind !== undefined || + command !== undefined || + normalizedTitle !== undefined || + textContent !== undefined; + const presentation = hasPresentationSeed + ? deriveToolActivityPresentation({ + itemType: canonicalItemTypeFromAcpToolKind(kind), + title, + detail: fallbackDetail, + data, + fallbackSummary: title ?? "Tool", + }) + : undefined; const status = normalizeToolCallStatus(input.status, options?.fallbackStatus); return { toolCallId, ...(kind ? { kind } : {}), - ...(title ? { title } : {}), + ...(presentation?.summary ? { title: presentation.summary } : {}), ...(status ? { status } : {}), ...(command ? { command } : {}), - ...(detail ? { detail } : {}), + ...(presentation?.detail ? { detail: presentation.detail } : {}), data, }; } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index bb4b03f4d06..1bdea85aaf8 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -122,6 +122,16 @@ type AcpStartState = } | { readonly _tag: "Started"; readonly result: AcpStartedState }; +interface AcpAssistantSegmentState { + readonly nextSegmentIndex: number; + readonly activeItemId?: string; +} + +interface EnsureActiveAssistantSegmentResult { + readonly itemId: string; + readonly startedEvent?: Extract; +} + export class AcpSessionRuntime extends ServiceMap.Service< AcpSessionRuntime, AcpSessionRuntimeShape @@ -150,6 +160,7 @@ const makeAcpSessionRuntime = ( const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); @@ -222,6 +233,7 @@ const makeAcpSessionRuntime = ( queue: eventQueue, modeStateRef, toolCallsRef, + assistantSegmentRef, params: notification, }), ); @@ -465,10 +477,23 @@ const makeAcpSessionRuntime = ( sessionId: started.sessionId, ...payload, } satisfies EffectAcpSchema.PromptRequest; - return runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), + return closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }).pipe( + Effect.andThen( + runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ), + ), + Effect.tap(() => + closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }), + ), ); }), ), @@ -504,11 +529,13 @@ const handleSessionUpdate = ({ queue, modeStateRef, toolCallsRef, + assistantSegmentRef, params, }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; readonly toolCallsRef: Ref.Ref>; + readonly assistantSegmentRef: Ref.Ref; readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => Effect.gen(function* () { @@ -520,7 +547,11 @@ const handleSessionUpdate = ({ } for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { - const merged = yield* Ref.modify(toolCallsRef, (current) => { + yield* closeActiveAssistantSegment({ + queue, + assistantSegmentRef, + }); + const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => { const previous = current.get(event.toolCall.toolCallId); const nextToolCall = mergeToolCallState(previous, event.toolCall); const next = new Map(current); @@ -529,8 +560,11 @@ const handleSessionUpdate = ({ } else { next.set(nextToolCall.toolCallId, nextToolCall); } - return [nextToolCall, next] as const; + return [{ previous, merged: nextToolCall }, next] as const; }); + if (!shouldEmitToolCallUpdate(previous, merged)) { + continue; + } yield* Queue.offer(queue, { _tag: "ToolCallUpdated", toolCall: merged, @@ -538,6 +572,24 @@ const handleSessionUpdate = ({ }); continue; } + if (event._tag === "ContentDelta") { + if (event.text.trim().length === 0) { + const assistantSegmentState = yield* Ref.get(assistantSegmentRef); + if (!assistantSegmentState.activeItemId) { + continue; + } + } + const itemId = yield* ensureActiveAssistantSegment({ + queue, + assistantSegmentRef, + sessionId: params.sessionId, + }); + yield* Queue.offer(queue, { + ...event, + itemId, + }); + continue; + } yield* Queue.offer(queue, event); } }); @@ -554,3 +606,79 @@ function updateModeState(modeState: AcpSessionModeState, nextModeId: string): Ac } : modeState; } + +function shouldEmitToolCallUpdate( + previous: AcpToolCallState | undefined, + next: AcpToolCallState, +): boolean { + if (next.status === "completed" || next.status === "failed") { + return true; + } + if (!next.detail) { + return false; + } + return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; +} + +const assistantItemId = (sessionId: string, segmentIndex: number) => + `assistant:${sessionId}:segment:${segmentIndex}`; + +const ensureActiveAssistantSegment = ({ + queue, + assistantSegmentRef, + sessionId, +}: { + readonly queue: Queue.Queue; + readonly assistantSegmentRef: Ref.Ref; + readonly sessionId: string; +}) => + Ref.modify( + assistantSegmentRef, + (current) => { + if (current.activeItemId) { + return [{ itemId: current.activeItemId }, current] as const; + } + const itemId = assistantItemId(sessionId, current.nextSegmentIndex); + return [ + { + itemId, + startedEvent: { + _tag: "AssistantItemStarted", + itemId, + } satisfies Extract, + }, + { + nextSegmentIndex: current.nextSegmentIndex + 1, + activeItemId: itemId, + } satisfies AcpAssistantSegmentState, + ] as const; + }, + ).pipe( + Effect.flatMap((result) => + result.startedEvent + ? Queue.offer(queue, result.startedEvent).pipe(Effect.as(result.itemId)) + : Effect.succeed(result.itemId), + ), + ); + +const closeActiveAssistantSegment = ({ + queue, + assistantSegmentRef, +}: { + readonly queue: Queue.Queue; + readonly assistantSegmentRef: Ref.Ref; +}) => + Ref.modify(assistantSegmentRef, (current) => { + if (!current.activeItemId) { + return [undefined, current] as const; + } + return [ + { + _tag: "AssistantItemCompleted", + itemId: current.activeItemId, + } satisfies AcpParsedSessionEvent, + { + nextSegmentIndex: current.nextSegmentIndex, + } satisfies AcpAssistantSegmentState, + ] as const; + }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void))); diff --git a/bun.lock b/bun.lock index c8c33e0092c..ad81f3f06b2 100644 --- a/bun.lock +++ b/bun.lock @@ -186,6 +186,9 @@ }, }, }, + "patchedDependencies": { + "effect@4.0.0-beta.42": "patches/effect@4.0.0-beta.42.patch", + }, "overrides": { "vite": "^8.0.0", }, diff --git a/package.json b/package.json index acc344bb938..39f5a4c12c1 100644 --- a/package.json +++ b/package.json @@ -70,5 +70,8 @@ "workerDirectory": [ "apps/web/public" ] + }, + "patchedDependencies": { + "effect@4.0.0-beta.42": "patches/effect@4.0.0-beta.42.patch" } } diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 5ffdfa2bdb7..45944c86116 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -34,6 +34,11 @@ const ElicitationCompleteNotification = jsonRpcNotification( "session/elicitation/complete", AcpSchema.ElicitationCompleteNotification, ); +const RequestPermissionRequest = jsonRpcRequest( + "session/request_permission", + AcpSchema.RequestPermissionRequest, +); +const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); @@ -158,7 +163,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { direction: "outgoing", stage: "raw", payload: - '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"},"id":"","headers":[]}\n', + '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"},"headers":[]}\n', }, ]); }), @@ -222,6 +227,84 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("preserves zero-valued ids for inbound core client requests", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(["session/request_permission"]), + }); + const inboundRequest = yield* Deferred.make(); + + yield* transport.serverProtocol + .run((_clientId, message) => Deferred.succeed(inboundRequest, message).pipe(Effect.asVoid)) + .pipe(Effect.forkScoped); + + yield* Queue.offer( + input, + yield* encodeJsonl(RequestPermissionRequest, { + jsonrpc: "2.0", + id: 0, + method: "session/request_permission", + params: { + sessionId: "session-1", + toolCall: { + toolCallId: "tool-1", + title: "Allow mock action", + }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }, + headers: [], + }), + ); + + const message = yield* Deferred.await(inboundRequest); + assert.deepEqual(message, { + _tag: "Request", + id: "0", + tag: "session/request_permission", + payload: { + sessionId: "session-1", + toolCall: { + toolCallId: "tool-1", + title: "Allow mock action", + }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }, + headers: [], + }); + + yield* transport.serverProtocol.send(0, { + _tag: "Exit", + requestId: "0", + exit: { + _tag: "Success", + value: { + outcome: { + outcome: "selected", + optionId: "allow", + }, + }, + }, + }); + + const outbound = yield* Queue.take(output); + assert.deepEqual( + yield* Schema.decodeEffect(Schema.fromJsonString(RequestPermissionResponse))(outbound), + { + jsonrpc: "2.0", + id: 0, + result: { + outcome: { + outcome: "selected", + optionId: "allow", + }, + }, + }, + ); + }), + ); + it.effect("cleans up interrupted extension requests before a late response arrives", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index b87aa582f85..b31ed66352b 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Console from "effect/Console"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -30,7 +31,7 @@ const program = Effect.gen(function* () { }), ); yield* acp.handleSessionUpdate((notification) => - Effect.logInfo("session/update", notification), + Console.log("session/update", JSON.stringify(notification)), ); const initialized = yield* acp.agent.initialize({ @@ -44,7 +45,7 @@ const program = Effect.gen(function* () { version: "0.0.0", }, }); - yield* Effect.logInfo("initialized", initialized); + yield* Console.log("initialized", JSON.stringify(initialized)); const session = yield* acp.agent.createSession({ cwd: process.cwd(), @@ -67,7 +68,7 @@ const program = Effect.gen(function* () { ], }); - yield* Effect.logInfo("prompt result", result); + yield* Console.log("prompt result", JSON.stringify(result)); yield* acp.agent.cancel({ sessionId: session.sessionId }); }).pipe(Effect.provide(acpLayer)); }); diff --git a/packages/shared/package.json b/packages/shared/package.json index d34d1ce4530..962f1893d87 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -32,6 +32,10 @@ "types": "./src/schemaJson.ts", "import": "./src/schemaJson.ts" }, + "./toolActivity": { + "types": "./src/toolActivity.ts", + "import": "./src/toolActivity.ts" + }, "./Struct": { "types": "./src/Struct.ts", "import": "./src/Struct.ts" diff --git a/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts new file mode 100644 index 00000000000..00e089c8019 --- /dev/null +++ b/packages/shared/src/toolActivity.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { deriveToolActivityPresentation } from "./toolActivity"; + +describe("toolActivity", () => { + it("normalizes command tools to a stable ran-command label", () => { + expect( + deriveToolActivityPresentation({ + itemType: "command_execution", + title: "Terminal", + detail: "Terminal", + data: { + command: "bun run lint", + }, + fallbackSummary: "Terminal", + }), + ).toEqual({ + summary: "Ran command", + detail: "bun run lint", + }); + }); + + it("uses structured file paths for read-file tools when available", () => { + expect( + deriveToolActivityPresentation({ + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + data: { + kind: "read", + locations: [{ path: "/tmp/app.ts" }], + }, + fallbackSummary: "Read File", + }), + ).toEqual({ + summary: "Read file", + detail: "/tmp/app.ts", + }); + }); + + it("drops duplicated generic read-file detail when no path is available", () => { + expect( + deriveToolActivityPresentation({ + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + data: { + kind: "read", + rawInput: {}, + }, + fallbackSummary: "Read File", + }), + ).toEqual({ + summary: "Read file", + }); + }); +}); diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts new file mode 100644 index 00000000000..5e2f18044f5 --- /dev/null +++ b/packages/shared/src/toolActivity.ts @@ -0,0 +1,257 @@ +import type { ToolLifecycleItemType } from "@t3tools/contracts"; + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeCommandValue(value: unknown): string | undefined { + const direct = asTrimmedString(value); + if (direct) { + return direct; + } + if (!Array.isArray(value)) { + return undefined; + } + const parts = value + .map((entry) => asTrimmedString(entry)) + .filter((entry): entry is string => entry !== undefined); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function stripTrailingExitCode(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + const match = /^(?[\s\S]*?)(?:\s*)\s*$/iu.exec(trimmed); + const output = match?.groups?.output?.trim() ?? trimmed; + return output.length > 0 ? output : undefined; +} + +function extractCommandFromTitle(title: string | undefined): string | undefined { + if (!title) { + return undefined; + } + const backtickMatch = /`([^`]+)`/u.exec(title); + return backtickMatch?.[1]?.trim() || undefined; +} + +function extractToolCommand(data: Record | undefined, title: string | undefined) { + const item = asRecord(data?.item); + const itemInput = asRecord(item?.input); + const itemResult = asRecord(item?.result); + const rawInput = asRecord(data?.rawInput); + const candidates = [ + normalizeCommandValue(item?.command), + normalizeCommandValue(itemInput?.command), + normalizeCommandValue(itemResult?.command), + normalizeCommandValue(data?.command), + normalizeCommandValue(rawInput?.command), + ]; + const direct = candidates.find((candidate) => candidate !== undefined); + if (direct) { + return direct; + } + const executable = asTrimmedString(rawInput?.executable); + const args = normalizeCommandValue(rawInput?.args); + if (executable && args) { + return `${executable} ${args}`; + } + if (executable) { + return executable; + } + return extractCommandFromTitle(title); +} + +function maybePathLike(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + if ( + value.includes("/") || + value.includes("\\") || + value.startsWith(".") || + /\.(?:[a-z0-9]{1,12})$/iu.test(value) + ) { + return value; + } + return undefined; +} + +function collectPaths(value: unknown, paths: string[], seen: Set, depth: number): void { + if (depth > 4 || paths.length >= 8) { + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectPaths(entry, paths, seen, depth + 1); + if (paths.length >= 8) { + return; + } + } + return; + } + const record = asRecord(value); + if (!record) { + return; + } + for (const key of ["path", "filePath", "relativePath", "filename", "newPath", "oldPath"]) { + const candidate = maybePathLike(asTrimmedString(record[key])); + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + paths.push(candidate); + if (paths.length >= 8) { + return; + } + } + for (const nestedKey of ["locations", "item", "input", "result", "rawInput", "data", "changes"]) { + if (!(nestedKey in record)) { + continue; + } + collectPaths(record[nestedKey], paths, seen, depth + 1); + if (paths.length >= 8) { + return; + } + } +} + +function extractPrimaryPath(data: Record | undefined): string | undefined { + const paths: string[] = []; + collectPaths(data, paths, new Set(), 0); + return paths[0]; +} + +function normalizeEquivalentValue(value: string | undefined): string | undefined { + const trimmed = asTrimmedString(value); + if (!trimmed) { + return undefined; + } + return trimmed + .replace(/\s+/gu, " ") + .replace(/\s+(?:complete|completed|started)\s*$/iu, "") + .trim(); +} + +function isEquivalent(left: string | undefined, right: string | undefined): boolean { + const normalizedLeft = normalizeEquivalentValue(left)?.toLowerCase(); + const normalizedRight = normalizeEquivalentValue(right)?.toLowerCase(); + return normalizedLeft !== undefined && normalizedLeft === normalizedRight; +} + +function classifyToolAction(input: { + readonly itemType?: ToolLifecycleItemType | null | undefined; + readonly title?: string | undefined; + readonly data?: Record | undefined; +}): "command" | "read" | "file_change" | "search" | "other" { + const itemType = input.itemType ?? undefined; + const kind = asTrimmedString(input.data?.kind)?.toLowerCase(); + const title = asTrimmedString(input.title)?.toLowerCase(); + if (itemType === "command_execution" || kind === "execute" || title === "terminal") { + return "command"; + } + if (kind === "read" || title === "read file") { + return "read"; + } + if ( + itemType === "file_change" || + kind === "edit" || + kind === "move" || + kind === "delete" || + kind === "write" + ) { + return "file_change"; + } + if (itemType === "web_search" || kind === "search" || title === "find" || title === "grep") { + return "search"; + } + return "other"; +} + +export interface ToolActivityPresentationInput { + readonly itemType?: ToolLifecycleItemType | null | undefined; + readonly title?: string | null | undefined; + readonly detail?: string | null | undefined; + readonly data?: unknown; + readonly fallbackSummary?: string | null | undefined; +} + +export interface ToolActivityPresentation { + readonly summary: string; + readonly detail?: string | undefined; +} + +export function deriveToolActivityPresentation( + input: ToolActivityPresentationInput, +): ToolActivityPresentation { + const title = asTrimmedString(input.title); + const detail = stripTrailingExitCode(asTrimmedString(input.detail)); + const fallbackSummary = asTrimmedString(input.fallbackSummary) ?? "Tool"; + const data = asRecord(input.data); + const command = extractToolCommand(data, title); + const primaryPath = extractPrimaryPath(data); + const action = classifyToolAction({ + itemType: input.itemType, + title, + data, + }); + + if (action === "command") { + return { + summary: "Ran command", + ...(command ? { detail: command } : {}), + }; + } + + if (action === "read") { + if (primaryPath) { + return { + summary: "Read file", + detail: primaryPath, + }; + } + return { + summary: "Read file", + }; + } + + if (action === "file_change") { + return { + summary: "Changed files", + ...(primaryPath ? { detail: primaryPath } : {}), + }; + } + + if (action === "search") { + const query = + asTrimmedString(asRecord(data?.rawInput)?.query) ?? + asTrimmedString(asRecord(data?.rawInput)?.pattern) ?? + asTrimmedString(asRecord(data?.rawInput)?.searchTerm); + return { + summary: "Searched files", + ...(query ? { detail: query } : {}), + }; + } + + if (detail && !isEquivalent(detail, title) && !isEquivalent(detail, fallbackSummary)) { + return { + summary: title ?? fallbackSummary, + detail, + }; + } + + return { + summary: title ?? fallbackSummary, + }; +} diff --git a/patches/effect@4.0.0-beta.42.patch b/patches/effect@4.0.0-beta.42.patch new file mode 100644 index 00000000000..ec5acf2c19d --- /dev/null +++ b/patches/effect@4.0.0-beta.42.patch @@ -0,0 +1,96 @@ +diff --git a/dist/unstable/rpc/RpcSerialization.js b/dist/unstable/rpc/RpcSerialization.js +index 26dc4f2608580af60306a99243a9abf2b3581640..7c25eeb940428fc2844c986cbb98b989518e7f5a 100644 +--- a/dist/unstable/rpc/RpcSerialization.js ++++ b/dist/unstable/rpc/RpcSerialization.js +@@ -139,7 +139,7 @@ function decodeJsonRpcRaw(decoded, batches) { + } + function decodeJsonRpcMessage(decoded) { + if ("method" in decoded) { +- if (!decoded.id && decoded.method.startsWith("@effect/rpc/")) { ++ if (decoded.id == null && decoded.method.startsWith("@effect/rpc/")) { + const tag = decoded.method.slice("@effect/rpc/".length); + const requestId = decoded.params?.requestId; + return requestId ? { +@@ -151,7 +151,7 @@ function decodeJsonRpcMessage(decoded) { + } + return { + _tag: "Request", +- id: decoded.id ? String(decoded.id) : "", ++ id: decoded.id != null ? String(decoded.id) : "", + tag: decoded.method, + payload: decoded.params ?? null, + headers: decoded.headers ?? [], +@@ -241,7 +241,7 @@ function encodeJsonRpcMessage(response) { + jsonrpc: "2.0", + method: response.tag, + params: response.payload, +- id: response.id && Number(response.id), ++ id: response.id !== "" ? Number(response.id) : undefined, + headers: response.headers, + traceId: response.traceId, + spanId: response.spanId, +@@ -271,14 +271,14 @@ function encodeJsonRpcMessage(response) { + if (response.exit._tag === "Success") { + return { + jsonrpc: "2.0", +- id: response.requestId ? Number(response.requestId) : undefined, ++ id: response.requestId !== "" ? Number(response.requestId) : undefined, + result: response.exit.value + }; + } + const error = response.exit.cause.find(failure => failure._tag === "Fail"); + return { + jsonrpc: "2.0", +- id: response.requestId ? Number(response.requestId) : undefined, ++ id: response.requestId !== "" ? Number(response.requestId) : undefined, + error: response.exit._tag === "Failure" ? { + _tag: "Cause", + code: error && Predicate.hasProperty(error, "code") ? Number(error.code) : 0, +diff --git a/src/unstable/rpc/RpcSerialization.ts b/src/unstable/rpc/RpcSerialization.ts +index 319effeabbc94237cd1a11799ac5d9bbd39d0170..66e82004de888b38f19a792886b461daa1789c55 100644 +--- a/src/unstable/rpc/RpcSerialization.ts ++++ b/src/unstable/rpc/RpcSerialization.ts +@@ -180,7 +180,7 @@ function decodeJsonRpcRaw( + + function decodeJsonRpcMessage(decoded: JsonRpcMessage): RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded { + if ("method" in decoded) { +- if (!decoded.id && decoded.method.startsWith("@effect/rpc/")) { ++ if (decoded.id == null && decoded.method.startsWith("@effect/rpc/")) { + const tag = decoded.method.slice("@effect/rpc/".length) as + | RpcMessage.FromServerEncoded["_tag"] + | Exclude +@@ -194,7 +194,7 @@ function decodeJsonRpcMessage(decoded: JsonRpcMessage): RpcMessage.FromClientEnc + } + return { + _tag: "Request", +- id: decoded.id ? String(decoded.id) : "", ++ id: decoded.id != null ? String(decoded.id) : "", + tag: decoded.method, + payload: decoded.params ?? null, + headers: decoded.headers ?? [], +@@ -308,7 +308,7 @@ function encodeJsonRpcMessage(response: RpcMessage.FromServerEncoded | RpcMessag + jsonrpc: "2.0", + method: response.tag, + params: response.payload, +- id: response.id && Number(response.id), ++ id: response.id !== "" ? Number(response.id) : undefined, + headers: response.headers, + traceId: response.traceId, + spanId: response.spanId, +@@ -335,14 +335,14 @@ function encodeJsonRpcMessage(response: RpcMessage.FromServerEncoded | RpcMessag + if (response.exit._tag === "Success") { + return { + jsonrpc: "2.0", +- id: response.requestId ? Number(response.requestId) : undefined, ++ id: response.requestId !== "" ? Number(response.requestId) : undefined, + result: response.exit.value + } as any + } + const error = response.exit.cause.find((failure) => failure._tag === "Fail") + return { + jsonrpc: "2.0", +- id: response.requestId ? Number(response.requestId) : undefined, ++ id: response.requestId !== "" ? Number(response.requestId) : undefined, + error: response.exit._tag === "Failure" ? + { + _tag: "Cause", From f0d44152fe4dda6c5ef10c9b39738ef339ee1628 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 8 Apr 2026 17:39:34 -0700 Subject: [PATCH 44/82] Add ACP model picker support for Cursor - Map parameterized Cursor ACP config options to model selections - Switch text generation and provider flows to ACP session runtime - Update tests and mock agent for new config handling --- apps/server/scripts/acp-mock-agent.ts | 144 ++++++- .../git/Layers/CursorTextGeneration.test.ts | 322 +++++++-------- .../src/git/Layers/CursorTextGeneration.ts | 208 ++++------ .../src/provider/Layers/CursorAdapter.ts | 66 ++-- .../provider/Layers/CursorProvider.test.ts | 147 +++++++ .../src/provider/Layers/CursorProvider.ts | 367 +++++++++++++++++- .../provider/acp/AcpJsonRpcConnection.test.ts | 45 +++ .../src/provider/acp/AcpSessionRuntime.ts | 22 +- .../provider/acp/CursorAcpCliProbe.test.ts | 30 +- .../src/provider/acp/CursorAcpSupport.test.ts | 123 ++++++ .../src/provider/acp/CursorAcpSupport.ts | 108 ++++++ 11 files changed, 1241 insertions(+), 341 deletions(-) create mode 100644 apps/server/src/provider/acp/CursorAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/CursorAcpSupport.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index de96c999264..ec728eea653 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -18,13 +18,131 @@ const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHO const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; +const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const sessionId = "mock-session-1"; let currentModeId = "ask"; let currentModelId = "default"; +let parameterizedModelPicker = false; +let currentReasoning = "medium"; +let currentContext = "272k"; +let currentFast = false; const cancelledSessions = new Set(); function configOptions(): ReadonlyArray { + if (parameterizedModelPicker) { + const baseOptions: Array = [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: currentModeId, + options: availableModes.map((mode) => ({ + value: mode.id, + name: mode.name, + ...(mode.description ? { description: mode.description } : {}), + })), + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: [ + { value: "default", name: "Auto" }, + { value: "composer-2", name: "Composer 2" }, + { value: "gpt-5.4", name: "GPT-5.4" }, + { value: "claude-opus-4-6", name: "Opus 4.6" }, + ], + }, + ]; + + switch (currentModelId) { + case "gpt-5.4": + return [ + ...baseOptions, + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: currentReasoning, + options: [ + { value: "none", name: "None" }, + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "extra-high", name: "Extra High" }, + ], + }, + { + id: "context", + name: "Context", + category: "model_config", + type: "select", + currentValue: currentContext, + options: [ + { value: "272k", name: "272K" }, + { value: "1m", name: "1M" }, + ], + }, + { + id: "fast", + name: "Fast", + category: "model_config", + type: "select", + currentValue: String(currentFast), + options: [ + { value: "false", name: "Off" }, + { value: "true", name: "Fast" }, + ], + }, + ]; + case "composer-2": + return [ + ...baseOptions, + { + id: "fast", + name: "Fast", + category: "model_config", + type: "select", + currentValue: String(currentFast), + options: [ + { value: "false", name: "Off" }, + { value: "true", name: "Fast" }, + ], + }, + ]; + case "claude-opus-4-6": + return [ + ...baseOptions, + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: currentReasoning, + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + { + id: "thinking", + name: "Thinking", + category: "model_config", + type: "boolean", + currentValue: true, + }, + ]; + default: + return baseOptions; + } + } + return [ { id: "model", @@ -70,10 +188,14 @@ function modeState(): AcpSchema.SessionModeState { const program = Effect.gen(function* () { const agent = yield* EffectAcpAgent.AcpAgent; - yield* agent.handleInitialize(() => - Effect.succeed({ - protocolVersion: 1, - agentCapabilities: { loadSession: true }, + yield* agent.handleInitialize((request) => + Effect.sync(() => { + parameterizedModelPicker = + request.clientCapabilities?._meta?.parameterizedModelPicker === true; + return { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + }; }), ); @@ -120,9 +242,21 @@ const program = Effect.gen(function* () { }, ); } + if (request.configId === "mode" && typeof request.value === "string") { + currentModeId = request.value; + } if (request.configId === "model" && typeof request.value === "string") { currentModelId = request.value; } + if (request.configId === "reasoning" && typeof request.value === "string") { + currentReasoning = request.value; + } + if (request.configId === "context" && typeof request.value === "string") { + currentContext = request.value; + } + if (request.configId === "fast") { + currentFast = request.value === true || request.value === "true"; + } return { configOptions: configOptions(), }; @@ -351,7 +485,7 @@ const program = Effect.gen(function* () { sessionId: requestedSessionId, update: { sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "hello from mock" }, + content: { type: "text", text: promptResponseText ?? "hello from mock" }, }, }); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts index 104fcb813b7..2c010a93ffb 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts @@ -1,13 +1,27 @@ +import * as path from "node:path"; +import * as os from "node:os"; +import { fileURLToPath } from "node:url"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, Layer } from "effect"; import { expect } from "vitest"; +import { ServerSettingsError } from "@t3tools/contracts"; + import { ServerConfig } from "../../config.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( @@ -18,159 +32,82 @@ const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe( Layer.provideMerge(NodeServices.layer), ); -function makeFakeAgentBinary( - dir: string, - input: { - result: string; - requireModel?: string; - requireTrust?: boolean; - requireMode?: string; - stdinMustContain?: string; - stderr?: string; - exitCode?: number; - }, -) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = path.join(dir, "bin"); - const agentPath = path.join(binDir, "agent"); - yield* fs.makeDirectory(binDir, { recursive: true }); - - yield* fs.writeFileString( - agentPath, - [ - "#!/bin/sh", - 'model=""', - 'seen_trust="0"', - 'mode=""', - "while [ $# -gt 0 ]; do", - ' if [ "$1" = "--model" ]; then', - " shift", - ' model="$1"', - " shift", - " continue", - " fi", - ' if [ "$1" = "--trust" ]; then', - ' seen_trust="1"', - " shift", - " continue", - " fi", - ' if [ "$1" = "--mode" ]; then', - " shift", - ' mode="$1"', - " shift", - " continue", - " fi", - " shift", - "done", - 'stdin_content="$(cat)"', - ...(input.requireModel !== undefined - ? [ - `if [ "$model" != "${input.requireModel}" ]; then`, - ' printf "%s\\n" "unexpected model: $model" >&2', - " exit 11", - "fi", - ] - : []), - ...(input.requireTrust - ? [ - 'if [ "$seen_trust" != "1" ]; then', - ' printf "%s\\n" "missing --trust" >&2', - " exit 12", - "fi", - ] - : []), - ...(input.requireMode !== undefined - ? [ - `if [ "$mode" != "${input.requireMode}" ]; then`, - ' printf "%s\\n" "unexpected mode: $mode" >&2', - " exit 13", - "fi", - ] - : []), - ...(input.stdinMustContain !== undefined - ? [ - `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, - ' printf "%s\\n" "stdin missing expected content" >&2', - " exit 14", - "fi", - ] - : []), - ...(input.stderr !== undefined - ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] - : []), - "cat <<'__T3CODE_FAKE_AGENT_OUTPUT__'", - JSON.stringify({ - type: "result", - subtype: "success", - is_error: false, - result: input.result, - }), - "__T3CODE_FAKE_AGENT_OUTPUT__", - `exit ${input.exitCode ?? 0}`, - "", - ].join("\n"), - ); - yield* fs.chmod(agentPath, 0o755); - return agentPath; - }); +function makeAcpAgentWrapper(dir: string, env: Record): string { + const binDir = path.join(dir, "bin"); + const agentPath = path.join(binDir, "agent"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + agentPath, + [ + "#!/bin/sh", + ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + 'if [ "$1" != "acp" ]; then', + ' printf "%s\\n" "unexpected args: $*" >&2', + " exit 11", + "fi", + `exec bun ${JSON.stringify(mockAgentPath)}`, + "", + ].join("\n"), + "utf8", + ); + chmodSync(agentPath, 0o755); + return agentPath; } -function withFakeAgentEnv( - input: { - result: string; - requireModel?: string; - requireTrust?: boolean; - requireMode?: string; - stdinMustContain?: string; - stderr?: string; - exitCode?: number; - }, +function withFakeAcpAgent( + env: Record, effect: Effect.Effect, -) { - return Effect.acquireUseRelease( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-cursor-text-" }); - const agentPath = yield* makeFakeAgentBinary(tempDir, input); - const serverSettings = yield* ServerSettingsService; - const previousSettings = yield* serverSettings.getSettings; - yield* serverSettings.updateSettings({ - providers: { - cursor: { - binaryPath: agentPath, - }, +): Effect.Effect { + return Effect.gen(function* () { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); + const agentPath = makeAcpAgentWrapper(tempDir, env); + const serverSettings = yield* ServerSettingsService; + const previousSettings = yield* serverSettings.getSettings; + + yield* serverSettings.updateSettings({ + providers: { + cursor: { + binaryPath: agentPath, }, - }); - return { serverSettings, previousBinaryPath: previousSettings.providers.cursor.binaryPath }; - }), - () => effect, - ({ serverSettings, previousBinaryPath }) => - serverSettings - .updateSettings({ - providers: { - cursor: { - binaryPath: previousBinaryPath, + }, + }); + + return yield* effect.pipe( + Effect.ensuring( + serverSettings + .updateSettings({ + providers: { + cursor: { + binaryPath: previousSettings.providers.cursor.binaryPath, + }, }, - }, - }) - .pipe(Effect.asVoid), - ); + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + rmSync(tempDir, { recursive: true, force: true }); + }), + ), + Effect.asVoid, + ), + ), + ); + }); } it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { - it.effect("uses agent CLI model ids instead of ACP bracket notation for commit messages", () => - withFakeAgentEnv( + it.effect("uses ACP model config options instead of raw CLI model ids", () => { + const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); + const requestLogPath = path.join(requestLogDir, "requests.ndjson"); + + return withFakeAcpAgent( { - result: JSON.stringify({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ subject: "Add generated commit message", - body: "- verify agent model mapping", + body: "- verify cursor acp model config path", }), - requireModel: "composer-2-fast", - requireTrust: true, - requireMode: "ask", - stdinMustContain: "Staged patch:", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -183,25 +120,92 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", modelSelection: { provider: "cursor", - model: "composer-2", - options: { fastMode: true }, + model: "gpt-5.4", + options: { + reasoning: "xhigh", + fastMode: true, + contextWindow: "1m", + }, }, }); expect(generated.subject).toBe("Add generated commit message"); - expect(generated.body).toBe("- verify agent model mapping"); + expect(generated.body).toBe("- verify cursor acp model config path"); + + const requests = readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method?: string; params?: Record }); + + expect( + requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, + ).toMatchObject({ + _meta: { + parameterizedModelPicker: true, + }, + }); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "mode" && + request.params?.value === "ask", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "model" && + request.params?.value === "gpt-5.4", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "reasoning" && + request.params?.value === "extra-high", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "context" && + request.params?.value === "1m", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "fast" && + request.params?.value === "true", + ), + ).toBe(true); + expect( + requests.find((request) => request.method === "session/prompt")?.params?.prompt, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Staged patch:"), + }), + ]), + ); + + rmSync(requestLogDir, { recursive: true, force: true }); }), - ), - ); + ); + }); - it.effect("accepts json objects with extra text around them from agent output", () => - withFakeAgentEnv( + it.effect("accepts json objects with extra assistant text around them", () => + withFakeAcpAgent( { - result: + T3_ACP_PROMPT_RESPONSE_TEXT: 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', - requireModel: "composer-2", - requireTrust: true, - requireMode: "ask", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -223,16 +227,12 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { ), ); - it.effect("generates thread titles through the Cursor provider", () => - withFakeAgentEnv( + it.effect("generates thread titles through Cursor ACP text generation", () => + withFakeAcpAgent( { - result: JSON.stringify({ + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ title: '"Trim reconnect spinner status after resume."', }), - requireModel: "composer-2", - requireTrust: true, - requireMode: "ask", - stdinMustContain: "You write concise thread titles for coding conversations.", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts index 36d557fac67..754f3737eb5 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -1,5 +1,5 @@ -import { Effect, Layer, Option, Schema, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, Layer, Option, Ref, Schema } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { CursorModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; @@ -16,24 +16,15 @@ import { buildPrContentPrompt, buildThreadTitlePrompt, } from "../Prompts.ts"; +import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts"; import { - normalizeCliError, - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, -} from "../Utils.ts"; -import { resolveCursorAgentModel } from "../../provider/Layers/CursorProvider.ts"; + applyCursorAcpModelSelection, + makeCursorAcpRuntime, +} from "../../provider/acp/CursorAcpSupport.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; const CURSOR_TIMEOUT_MS = 180_000; -const CursorOutputEnvelope = Schema.Struct({ - type: Schema.String, - subtype: Schema.optional(Schema.String), - is_error: Schema.optional(Schema.Boolean), - result: Schema.optional(Schema.String), -}); - function extractJsonObject(raw: string): string { const trimmed = raw.trim(); if (trimmed.length === 0) { @@ -82,25 +73,35 @@ function extractJsonObject(raw: string): string { return trimmed.slice(start); } +function mapCursorAcpError( + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", + detail: string, + cause: unknown, +): TextGenerationError { + return new TextGenerationError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function isTextGenerationError(error: unknown): error is TextGenerationError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "TextGenerationError" + ); +} + const makeCursorTextGeneration = Effect.gen(function* () { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverSettingsService = yield* Effect.service(ServerSettingsService); - const readStreamAsString = ( - operation: string, - stream: Stream.Stream, - ): Effect.Effect => - stream.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - Effect.mapError((cause) => - normalizeCliError("agent", operation, cause, "Failed to collect process output"), - ), - ); - const runCursorJson = ({ operation, cwd, @@ -124,74 +125,47 @@ const makeCursorTextGeneration = Effect.gen(function* () { (settings) => settings.providers.cursor, ).pipe(Effect.catch(() => Effect.undefined)); - const runCursorCommand = Effect.gen(function* () { - const command = ChildProcess.make( - cursorSettings?.binaryPath || "agent", - [ - "-p", - "--trust", - "--mode", - "ask", - "--output-format", - "json", - "--model", - resolveCursorAgentModel(modelSelection.model, modelSelection.options), - ], - { - cwd, - shell: process.platform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, - ); - - const child = yield* commandSpawner - .spawn(command) - .pipe( - Effect.mapError((cause) => - normalizeCliError("agent", operation, cause, "Failed to spawn Cursor Agent process"), - ), - ); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - readStreamAsString(operation, child.stdout), - readStreamAsString(operation, child.stderr), - child.exitCode.pipe( - Effect.mapError((cause) => - normalizeCliError( - "agent", - operation, - cause, - "Failed to read Cursor Agent exit code", - ), - ), - ), - ], - { concurrency: "unbounded" }, - ); - - const commandOutput = { stdout, stderr, exitCode }; - - if (exitCode !== 0) { - const stderrDetail = stderr.trim(); - const stdoutDetail = stdout.trim(); - const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; - return yield* new TextGenerationError({ - operation, - detail: - detail.length > 0 - ? `Cursor Agent command failed: ${detail}` - : `Cursor Agent command failed with code ${exitCode}.`, - }); - } + const outputRef = yield* Ref.make(""); + const runtime = yield* makeCursorAcpRuntime({ + cursorSettings, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }); - return commandOutput; + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); }); - const commandOutput = yield* runCursorCommand.pipe( - Effect.scoped, + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode("ask")); + yield* applyCursorAcpModelSelection({ + runtime, + model: modelSelection.model, + modelOptions: modelSelection.options, + mapError: ({ cause, configId, step }) => + mapCursorAcpError( + operation, + step === "set-config-option" + ? `Failed to set Cursor ACP config option "${configId}" for text generation.` + : "Failed to set Cursor ACP base model for text generation.", + cause, + ), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( Effect.timeoutOption(CURSOR_TIMEOUT_MS), Effect.flatMap( Option.match({ @@ -205,38 +179,21 @@ const makeCursorTextGeneration = Effect.gen(function* () { onSome: (value) => Effect.succeed(value), }), ), - ); - - const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(CursorOutputEnvelope))( - commandOutput.stdout, - ).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Cursor Agent returned unexpected output format.", - cause, - }), - ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), ), ); - if ( - envelope.type !== "result" || - envelope.subtype !== "success" || - envelope.is_error === true - ) { - return yield* new TextGenerationError({ - operation, - detail: "Cursor Agent returned an unsuccessful result.", - }); - } - - const rawResult = envelope.result?.trim(); + const rawResult = (yield* Ref.get(outputRef)).trim(); if (!rawResult) { return yield* new TextGenerationError({ operation, - detail: "Cursor Agent returned empty output.", + detail: + promptResult.stopReason === "cancelled" + ? "Cursor ACP request was cancelled." + : "Cursor Agent returned empty output.", }); } @@ -253,7 +210,14 @@ const makeCursorTextGeneration = Effect.gen(function* () { ), ), ); - }); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), + ), + Effect.scoped, + ); const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( "CursorTextGeneration.generateCommitMessage", diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 5914d1abd06..528a0481f7d 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -27,7 +27,6 @@ import { Layer, Queue, Random, - Scope, Stream, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -42,8 +41,8 @@ import { ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import { AcpSessionRuntime, type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, @@ -58,6 +57,7 @@ import { parsePermissionRequest, } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; +import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; import { CursorAskQuestionRequest, CursorCreatePlanRequest, @@ -67,7 +67,7 @@ import { extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; -import { resolveCursorAcpModelId } from "./CursorProvider.ts"; +import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "cursor" as const; @@ -364,16 +364,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { yield* stopSessionInternal(existing); } - const spawnOptions = yield* serverSettingsService.getSettings.pipe( + const cursorSettings = yield* serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.providers.cursor), - Effect.map((cursorSettings) => ({ - command: cursorSettings.binaryPath, - args: [ - ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), - "acp", - ], - cwd, - })), Effect.mapError( (error) => new ProviderAdapterProcessError({ @@ -396,22 +388,14 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { threadId: input.threadId, }); - const acpContextScope = yield* Scope.make("sequential"); - const acpContext = yield* Layer.build( - AcpSessionRuntime.layer({ - spawn: spawnOptions, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - authMethodId: "cursor_login", - ...acpNativeLoggers, - }).pipe( - Layer.provide( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - ), - ), - ).pipe( - Effect.provideService(Scope.Scope, acpContextScope), + const acp = yield* makeCursorAcpRuntime({ + cursorSettings, + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }).pipe( Effect.mapError( (cause) => new ProviderAdapterProcessError({ @@ -422,7 +406,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }), ), ); - const acp = yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); const started = yield* Effect.gen(function* () { yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => Effect.gen(function* () { @@ -713,18 +696,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const turnId = TurnId.makeUnsafe(crypto.randomUUID()); const turnModelSelection = input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; - const model = resolveCursorAcpModelId( - turnModelSelection?.model ?? ctx.session.model, - turnModelSelection?.options, - ); - - yield* ctx.acp - .setModel(model) - .pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", error), - ), - ); + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = resolveCursorAcpBaseModelId(model); + yield* applyCursorAcpModelSelection({ + runtime: ctx.acp, + model, + modelOptions: turnModelSelection?.options, + mapError: ({ cause }) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + }); ctx.activeTurnId = turnId; ctx.lastPlanFingerprint = undefined; ctx.session = { @@ -756,7 +736,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { provider: PROVIDER, threadId: input.threadId, turnId, - payload: { model }, + payload: { model: resolvedModel }, }); const promptParts: Array = []; @@ -818,7 +798,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ...ctx.session, activeTurnId: turnId, updatedAt: yield* nowIso, - model, + model: resolvedModel, }; yield* offerRuntimeEvent({ diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index dc397c443a3..16fd5d8b887 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1,11 +1,92 @@ import { describe, expect, it } from "vitest"; +import type * as EffectAcpSchema from "effect-acp/schema"; import { + buildCursorCapabilitiesFromConfigOptions, getCursorModelCapabilities, + resolveCursorAcpBaseModelId, + resolveCursorAcpConfigUpdates, resolveCursorAgentModel, resolveCursorAcpModelId, } from "./CursorProvider.ts"; +const parameterizedGpt54ConfigOptions = [ + { + type: "select", + currentValue: "gpt-5.4", + options: [{ name: "GPT-5.4", value: "gpt-5.4" }], + category: "model", + id: "model", + name: "Model", + }, + { + type: "select", + currentValue: "medium", + options: [ + { name: "None", value: "none" }, + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + { name: "Extra High", value: "extra-high" }, + ], + category: "thought_level", + id: "reasoning", + name: "Reasoning", + }, + { + type: "select", + currentValue: "272k", + options: [ + { name: "272K", value: "272k" }, + { name: "1M", value: "1m" }, + ], + category: "model_config", + id: "context", + name: "Context", + }, + { + type: "select", + currentValue: "false", + options: [ + { name: "Off", value: "false" }, + { name: "Fast", value: "true" }, + ], + category: "model_config", + id: "fast", + name: "Fast", + }, +] satisfies ReadonlyArray; + +const parameterizedClaudeConfigOptions = [ + { + type: "select", + currentValue: "claude-opus-4-6", + options: [{ name: "Opus 4.6", value: "claude-opus-4-6" }], + category: "model", + id: "model", + name: "Model", + }, + { + type: "select", + currentValue: "high", + options: [ + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + ], + category: "thought_level", + id: "reasoning", + name: "Reasoning", + }, + { + type: "boolean", + currentValue: true, + category: "model_config", + id: "thinking", + name: "Thinking", + }, +] satisfies ReadonlyArray; + describe("resolveCursorAcpModelId", () => { it("emits ACP model ids that match explicit Cursor ACP config values", () => { expect(resolveCursorAcpModelId("composer-2", { fastMode: true })).toBe("composer-2[fast=true]"); @@ -45,6 +126,72 @@ describe("getCursorModelCapabilities", () => { }); }); +describe("buildCursorCapabilitiesFromConfigOptions", () => { + it("derives model capabilities from parameterized Cursor ACP config options", () => { + expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual({ + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "272k", label: "272K", isDefault: true }, + { value: "1m", label: "1M" }, + ], + promptInjectedEffortLevels: [], + }); + }); + + it("detects boolean thinking toggles from model_config options", () => { + expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual({ + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }); + }); +}); + +describe("resolveCursorAcpBaseModelId", () => { + it("drops parameterized ACP traits and preserves base model ids", () => { + expect(resolveCursorAcpBaseModelId("gpt-5.4[reasoning=medium,context=272k]")).toBe("gpt-5.4"); + expect(resolveCursorAcpBaseModelId("composer-2")).toBe("composer-2"); + expect(resolveCursorAcpBaseModelId("auto")).toBe("auto"); + }); +}); + +describe("resolveCursorAcpConfigUpdates", () => { + it("maps Cursor model options onto separate ACP config option updates", () => { + expect( + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { + reasoning: "xhigh", + fastMode: true, + contextWindow: "1m", + }), + ).toEqual([ + { configId: "reasoning", value: "extra-high" }, + { configId: "context", value: "1m" }, + { configId: "fast", value: "true" }, + ]); + }); + + it("maps boolean thinking toggles when the model exposes them separately", () => { + expect( + resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, { + thinking: false, + }), + ).toEqual([{ configId: "thinking", value: false }]); + }); +}); + describe("resolveCursorAgentModel", () => { it("maps canonical base slugs onto agent CLI model ids", () => { expect(resolveCursorAgentModel("composer-2", { fastMode: true })).toBe("composer-2-fast"); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 6410da05a47..fef1c8c5e8a 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -8,6 +8,7 @@ import type { ServerProviderState, ServerSettingsError, } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -21,6 +22,7 @@ import { } from "../providerSnapshot"; import { makeManagedServerProvider } from "../makeManagedServerProvider"; import { CursorProvider } from "../Services/CursorProvider"; +import { AcpSessionRuntime } from "../acp/AcpSessionRuntime"; import { ServerSettingsService } from "../../serverSettings"; const PROVIDER = "cursor" as const; @@ -183,6 +185,348 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { + _meta: { + parameterizedModelPicker: true, + }, +} satisfies NonNullable; + +interface CursorSessionSelectOption { + readonly value: string; + readonly name: string; +} + +interface CursorAcpDiscoveredModel { + readonly slug: string; + readonly name: string; + readonly capabilities: ModelCapabilities; +} + +function flattenSessionConfigSelectOptions( + configOption: EffectAcpSchema.SessionConfigOption | undefined, +): ReadonlyArray { + if (!configOption || configOption.type !== "select") { + return []; + } + return configOption.options.flatMap((entry) => + "value" in entry + ? [{ value: entry.value.trim(), name: entry.name.trim() } satisfies CursorSessionSelectOption] + : entry.options.map( + (option) => + ({ + value: option.value.trim(), + name: option.name.trim(), + }) satisfies CursorSessionSelectOption, + ), + ); +} + +function normalizeCursorAcpModelSlug(modelId: string): string { + const trimmed = modelId.trim(); + const base = trimmed.includes("[") ? trimmed.slice(0, trimmed.indexOf("[")) : trimmed; + return normalizeModelSlug(base, PROVIDER) ?? base; +} + +function normalizeCursorThoughtLevelValue(value: string | null | undefined): string | undefined { + const normalized = value?.trim().toLowerCase(); + switch (normalized) { + case "low": + case "medium": + case "high": + return normalized; + case "xhigh": + case "extra-high": + case "extra high": + return "xhigh"; + default: + return undefined; + } +} + +function findCursorModelConfigOption( + configOptions: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + return configOptions.find((option) => option.category === "model"); +} + +function isCursorContextConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return id === "context" || id === "context_size" || name.includes("context"); +} + +function isCursorFastConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return id === "fast" || name === "fast" || name.includes("fast mode"); +} + +function isCursorThinkingConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return id === "thinking" || name.includes("thinking"); +} + +function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + if (option.type === "boolean") { + return true; + } + if (option.type !== "select") { + return false; + } + const values = new Set( + flattenSessionConfigSelectOptions(option).map((entry) => entry.value.trim().toLowerCase()), + ); + return values.has("true") && values.has("false"); +} + +export function buildCursorCapabilitiesFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ModelCapabilities { + if (!configOptions || configOptions.length === 0) { + return EMPTY_CAPABILITIES; + } + + const reasoningConfig = configOptions.find((option) => option.category === "thought_level"); + const reasoningEffortLevels = + reasoningConfig?.type === "select" + ? flattenSessionConfigSelectOptions(reasoningConfig).flatMap((entry) => { + const normalizedValue = normalizeCursorThoughtLevelValue(entry.value); + if (!normalizedValue) { + return []; + } + return [ + { + value: normalizedValue, + label: entry.name, + ...(normalizeCursorThoughtLevelValue(reasoningConfig.currentValue) === normalizedValue + ? { isDefault: true } + : {}), + }, + ]; + }) + : []; + + const contextOption = configOptions.find( + (option) => option.category === "model_config" && isCursorContextConfigOption(option), + ); + const contextWindowOptions = + contextOption?.type === "select" + ? flattenSessionConfigSelectOptions(contextOption).map((entry) => { + if (contextOption.currentValue === entry.value) { + return { + value: entry.value, + label: entry.name, + isDefault: true, + }; + } + return { + value: entry.value, + label: entry.name, + }; + }) + : []; + + const fastOption = configOptions.find( + (option) => option.category === "model_config" && isCursorFastConfigOption(option), + ); + const thinkingOption = configOptions.find( + (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), + ); + + return { + reasoningEffortLevels, + supportsFastMode: fastOption ? isBooleanLikeConfigOption(fastOption) : false, + supportsThinkingToggle: thinkingOption ? isBooleanLikeConfigOption(thinkingOption) : false, + contextWindowOptions, + promptInjectedEffortLevels: [], + }; +} + +function buildCursorDiscoveredModels( + discoveredModels: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return discoveredModels.flatMap((model) => { + if (!model.slug || seen.has(model.slug)) { + return []; + } + seen.add(model.slug); + return [ + { + slug: model.slug, + name: model.name, + isCustom: false, + capabilities: model.capabilities, + } satisfies ServerProviderModel, + ]; + }); +} + +function normalizeCursorConfigOptionToken(value: string | null | undefined): string { + return ( + value + ?.trim() + .toLowerCase() + .replace(/[\s_-]+/g, "-") ?? "" + ); +} + +function findCursorSelectOptionValue( + configOption: EffectAcpSchema.SessionConfigOption | undefined, + matcher: (option: CursorSessionSelectOption) => boolean, +): string | undefined { + return flattenSessionConfigSelectOptions(configOption).find(matcher)?.value; +} + +function findCursorBooleanConfigValue( + configOption: EffectAcpSchema.SessionConfigOption | undefined, + requested: boolean, +): string | boolean | undefined { + if (!configOption) { + return undefined; + } + if (configOption.type === "boolean") { + return requested; + } + return findCursorSelectOptionValue( + configOption, + (option) => normalizeCursorConfigOptionToken(option.value) === String(requested), + ); +} + +export function resolveCursorAcpBaseModelId(model: string | null | undefined): string { + const normalized = normalizeModelSlug(model, PROVIDER) ?? "default"; + return normalized.includes("[") ? normalized.slice(0, normalized.indexOf("[")) : normalized; +} + +export function resolveCursorAcpConfigUpdates( + configOptions: ReadonlyArray | null | undefined, + modelOptions: CursorModelOptions | null | undefined, +): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> { + if (!configOptions || configOptions.length === 0) { + return []; + } + + const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = []; + + const reasoningOption = configOptions.find((option) => option.category === "thought_level"); + const requestedReasoning = normalizeCursorThoughtLevelValue(modelOptions?.reasoning); + if (reasoningOption && requestedReasoning) { + const value = findCursorSelectOptionValue(reasoningOption, (option) => { + const normalizedValue = normalizeCursorThoughtLevelValue(option.value); + const normalizedName = normalizeCursorThoughtLevelValue(option.name); + return normalizedValue === requestedReasoning || normalizedName === requestedReasoning; + }); + if (value) { + updates.push({ configId: reasoningOption.id, value }); + } + } + + const contextOption = configOptions.find( + (option) => option.category === "model_config" && isCursorContextConfigOption(option), + ); + if (contextOption && modelOptions?.contextWindow) { + const value = findCursorSelectOptionValue( + contextOption, + (option) => + normalizeCursorConfigOptionToken(option.value) === + normalizeCursorConfigOptionToken(modelOptions.contextWindow) || + normalizeCursorConfigOptionToken(option.name) === + normalizeCursorConfigOptionToken(modelOptions.contextWindow), + ); + if (value) { + updates.push({ configId: contextOption.id, value }); + } + } + + const fastOption = configOptions.find( + (option) => option.category === "model_config" && isCursorFastConfigOption(option), + ); + if (fastOption && modelOptions?.fastMode === true) { + const value = findCursorBooleanConfigValue(fastOption, true); + if (value !== undefined) { + updates.push({ configId: fastOption.id, value }); + } + } + + const thinkingOption = configOptions.find( + (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), + ); + if (thinkingOption && typeof modelOptions?.thinking === "boolean") { + const value = findCursorBooleanConfigValue(thinkingOption, modelOptions.thinking); + if (value !== undefined) { + updates.push({ configId: thinkingOption.id, value }); + } + } + + return updates; +} + +const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + spawn: { + command: cursorSettings.binaryPath, + args: [ + ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), + "acp", + ], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + authMethodId: "cursor_login", + clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, + }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), + ); + const acp = yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + const started = yield* acp.start(); + const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; + const modelOption = findCursorModelConfigOption(initialConfigOptions); + const modelChoices = flattenSessionConfigSelectOptions(modelOption); + if (!modelOption || modelChoices.length === 0) { + return [] as const; + } + + const fallbackBySlug = new Map(BUILT_IN_MODELS.map((model) => [model.slug, model] as const)); + const currentModelValue = + modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; + + const discoveredModels = yield* Effect.forEach( + modelChoices, + (modelChoice) => + Effect.gen(function* () { + const slug = normalizeCursorAcpModelSlug(modelChoice.value); + let configOptions: ReadonlyArray = + initialConfigOptions; + if (currentModelValue !== modelChoice.value) { + configOptions = yield* acp.setConfigOption(modelOption.id, modelChoice.value).pipe( + Effect.map((response) => response.configOptions ?? []), + Effect.catch(() => + Effect.succeed>([]), + ), + ); + } + const fallbackCapabilities = fallbackBySlug.get(slug)?.capabilities ?? EMPTY_CAPABILITIES; + return { + slug, + name: modelChoice.name, + capabilities: + configOptions.length > 0 + ? buildCursorCapabilitiesFromConfigOptions(configOptions) + : fallbackCapabilities, + } satisfies CursorAcpDiscoveredModel; + }), + { concurrency: 1 }, + ); + + return buildCursorDiscoveredModels(discoveredModels); + }).pipe(Effect.scoped); + export function getCursorModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = normalizeModelSlug(model, "cursor"); return ( @@ -422,7 +766,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( Effect.map((settings) => settings.providers.cursor), ); const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings( + const fallbackModels = providerModelsFromSettings( BUILT_IN_MODELS, PROVIDER, cursorSettings.customModels, @@ -433,7 +777,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( provider: PROVIDER, enabled: false, checkedAt, - models, + models: fallbackModels, probe: { installed: false, version: null, @@ -456,7 +800,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( provider: PROVIDER, enabled: cursorSettings.enabled, checkedAt, - models, + models: fallbackModels, probe: { installed: !isCommandMissingCause(error), version: null, @@ -474,7 +818,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( provider: PROVIDER, enabled: cursorSettings.enabled, checkedAt, - models, + models: fallbackModels, probe: { installed: true, version: null, @@ -486,6 +830,21 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( } const parsed = parseCursorAboutOutput(aboutProbe.success.value); + let discoveredModels = Option.none>(); + if (parsed.auth.status !== "unauthenticated") { + discoveredModels = yield* discoverCursorModelsViaAcp(cursorSettings).pipe( + Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.catch(() => Effect.succeed(Option.none>())), + ); + } + const models = providerModelsFromSettings( + Option.getOrElse( + Option.filter(discoveredModels, (models) => models.length > 0), + () => BUILT_IN_MODELS, + ), + PROVIDER, + cursorSettings.customModels, + ); return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index bf3c4923732..b91dcb67180 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -16,6 +16,51 @@ const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts") const bunExe = "bun"; describe("AcpSessionRuntime", () => { + it.effect("merges custom initialize client capabilities into the ACP handshake", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + const initializeStarted = requestEvents.find( + (event) => event.method === "initialize" && event.status === "started", + ); + expect(initializeStarted?.payload).toMatchObject({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + _meta: { parameterizedModelPicker: true }, + }, + }); + + yield* runtime.close; + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 1bdea85aaf8..76cd8b0687f 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -39,6 +39,7 @@ export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; readonly resumeSessionId?: string; + readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: { readonly name: string; readonly version: string; @@ -89,6 +90,7 @@ export interface AcpSessionRuntimeShape { readonly start: () => Effect.Effect; readonly events: Stream.Stream; readonly getModeState: Effect.Effect; + readonly getConfigOptions: Effect.Effect>; readonly prompt: ( payload: Omit, ) => Effect.Effect; @@ -239,6 +241,20 @@ const makeAcpSessionRuntime = ( ); const close = Scope.close(runtimeScope, Exit.void).pipe(Effect.asVoid); + const initializeClientCapabilities = { + fs: { + readTextFile: false, + writeTextFile: false, + ...options.clientCapabilities?.fs, + }, + terminal: options.clientCapabilities?.terminal ?? false, + ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}), + ...(options.clientCapabilities?.elicitation + ? { elicitation: options.clientCapabilities.elicitation } + : {}), + ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}), + } satisfies NonNullable; + const getStartedState = Effect.gen(function* () { const state = yield* Ref.get(startStateRef); if (state._tag === "Started") { @@ -338,10 +354,7 @@ const makeAcpSessionRuntime = ( const startOnce = Effect.gen(function* () { const initializePayload = { protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, + clientCapabilities: initializeClientCapabilities, clientInfo: options.clientInfo, } satisfies EffectAcpSchema.InitializeRequest; @@ -470,6 +483,7 @@ const makeAcpSessionRuntime = ( start: () => start, events: Stream.fromQueue(eventQueue), getModeState: Ref.get(modeStateRef), + getConfigOptions: Ref.get(configOptionsRef), prompt: (payload) => getStartedState.pipe( Effect.flatMap((started) => { diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index 51182de1206..e36b9f34b6a 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -26,6 +26,11 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", cwd: process.cwd(), }, cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, clientInfo: { name: "t3-probe", version: "0.0.0" }, authMethodId: "cursor_login", }), @@ -49,7 +54,14 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", if (Array.isArray(configOptions)) { const modelConfig = configOptions.find((opt) => opt.category === "model"); + const parameterizedOptions = configOptions.filter( + (opt) => opt.category === "thought_level" || opt.category === "model_config", + ); console.log("Model config option:", JSON.stringify(modelConfig, null, 2)); + console.log( + "Parameterized model config options:", + JSON.stringify(parameterizedOptions, null, 2), + ); expect(modelConfig).toBeDefined(); expect(typeof modelConfig?.id).toBe("string"); } @@ -64,6 +76,11 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", cwd: process.cwd(), }, cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, clientInfo: { name: "t3-probe", version: "0.0.0" }, }), ), @@ -88,15 +105,19 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", } const setResult: EffectAcpSchema.SetSessionConfigOptionResponse = - yield* runtime.setConfigOption(modelConfigId, "composer-2"); + yield* runtime.setConfigOption(modelConfigId, "gpt-5.4"); console.log("session/set_config_option result:", JSON.stringify(setResult, null, 2)); if (Array.isArray(setResult.configOptions)) { const modelConfig = setResult.configOptions.find((opt) => opt.category === "model"); + const parameterizedOptions = setResult.configOptions.filter( + (opt) => opt.category === "thought_level" || opt.category === "model_config", + ); if (modelConfig?.type === "select") { - expect(modelConfig.currentValue).toBe("composer-2"); + expect(modelConfig.currentValue).toBe("gpt-5.4"); } + expect(parameterizedOptions.length).toBeGreaterThan(0); } yield* runtime.close; }).pipe( @@ -109,6 +130,11 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", cwd: process.cwd(), }, cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, clientInfo: { name: "t3-probe", version: "0.0.0" }, }), ), diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts new file mode 100644 index 00000000000..2866130c29f --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -0,0 +1,123 @@ +import { Effect } from "effect"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { describe, expect, it } from "vitest"; + +import { applyCursorAcpModelSelection, buildCursorAcpSpawnInput } from "./CursorAcpSupport.ts"; + +const parameterizedGpt54ConfigOptions: ReadonlyArray = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "gpt-5.4", + options: [{ value: "gpt-5.4", name: "GPT-5.4" }], + }, + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "extra-high", name: "Extra High" }, + ], + }, + { + id: "context", + name: "Context", + category: "model_config", + type: "select", + currentValue: "272k", + options: [ + { value: "272k", name: "272K" }, + { value: "1m", name: "1M" }, + ], + }, + { + id: "fast", + name: "Fast", + category: "model_config", + type: "select", + currentValue: "false", + options: [ + { value: "false", name: "Off" }, + { value: "true", name: "Fast" }, + ], + }, +]; + +describe("buildCursorAcpSpawnInput", () => { + it("builds the default Cursor ACP command", () => { + expect(buildCursorAcpSpawnInput(undefined, "/tmp/project")).toEqual({ + command: "agent", + args: ["acp"], + cwd: "/tmp/project", + }); + }); + + it("includes the configured api endpoint when present", () => { + expect( + buildCursorAcpSpawnInput( + { + binaryPath: "/usr/local/bin/agent", + apiEndpoint: "http://localhost:3000", + }, + "/tmp/project", + ), + ).toEqual({ + command: "/usr/local/bin/agent", + args: ["-e", "http://localhost:3000", "acp"], + cwd: "/tmp/project", + }); + }); +}); + +describe("applyCursorAcpModelSelection", () => { + it("sets the base model before applying separate config options", async () => { + const calls: Array< + | { readonly type: "model"; readonly value: string } + | { readonly type: "config"; readonly configId: string; readonly value: string | boolean } + > = []; + + const runtime = { + getConfigOptions: Effect.succeed(parameterizedGpt54ConfigOptions), + setModel: (value: string) => + Effect.sync(() => { + calls.push({ type: "model", value }); + }), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ type: "config", configId, value }); + }), + }; + + await Effect.runPromise( + applyCursorAcpModelSelection({ + runtime, + model: "gpt-5.4[reasoning=medium,context=272k]", + modelOptions: { + reasoning: "xhigh", + contextWindow: "1m", + fastMode: true, + }, + mapError: ({ step, configId, cause }) => + new Error( + step === "set-config-option" + ? `failed to set config option ${configId}: ${cause.message}` + : `failed to set model: ${cause.message}`, + ), + }), + ); + + expect(calls).toEqual([ + { type: "model", value: "gpt-5.4" }, + { type: "config", configId: "reasoning", value: "extra-high" }, + { type: "config", configId: "context", value: "1m" }, + { type: "config", configId: "fast", value: "true" }, + ]); + }); +}); diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts new file mode 100644 index 00000000000..9634cc12d67 --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -0,0 +1,108 @@ +import { type CursorModelOptions, type CursorSettings } from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { + CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, + resolveCursorAcpBaseModelId, + resolveCursorAcpConfigUpdates, +} from "../Layers/CursorProvider.ts"; +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionRuntimeShape, + type AcpSpawnInput, +} from "./AcpSessionRuntime.ts"; + +type CursorAcpRuntimeCursorSettings = Pick; + +export interface CursorAcpRuntimeInput extends Omit< + AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined; +} + +export interface CursorAcpModelSelectionErrorContext { + readonly cause: EffectAcpErrors.AcpError; + readonly step: "set-config-option" | "set-model"; + readonly configId?: string; +} + +export function buildCursorAcpSpawnInput( + cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined, + cwd: string, +): AcpSpawnInput { + return { + command: cursorSettings?.binaryPath || "agent", + args: [ + ...(cursorSettings?.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), + "acp", + ], + cwd, + }; +} + +export const makeCursorAcpRuntime = ( + input: CursorAcpRuntimeInput, +): Effect.Effect => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd), + authMethodId: "cursor_login", + clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + }).pipe(Effect.scoped); + +interface CursorAcpModelSelectionRuntime { + readonly getConfigOptions: AcpSessionRuntimeShape["getConfigOptions"]; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; +} + +export function applyCursorAcpModelSelection(input: { + readonly runtime: CursorAcpModelSelectionRuntime; + readonly model: string | null | undefined; + readonly modelOptions: CursorModelOptions | null | undefined; + readonly mapError: (context: CursorAcpModelSelectionErrorContext) => E; +}): Effect.Effect { + return Effect.gen(function* () { + yield* input.runtime.setModel(resolveCursorAcpBaseModelId(input.model)).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-model", + }), + ), + ); + + const configUpdates = resolveCursorAcpConfigUpdates( + yield* input.runtime.getConfigOptions, + input.modelOptions, + ); + for (const update of configUpdates) { + yield* input.runtime.setConfigOption(update.configId, update.value).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-config-option", + configId: update.configId, + }), + ), + ); + } + }); +} From 1dea9cce1440313fe44a5bc3d7994ed89cf1b5bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 8 Apr 2026 17:50:15 -0700 Subject: [PATCH 45/82] Gate Cursor parameterized model picker on preview channel - Parse Cursor CLI version/channel for ACP preview gating - Fall back with an explanatory status when requirements are not met - Co-authored-by: codex --- .../provider/Layers/CursorProvider.test.ts | 45 +++++++++ .../src/provider/Layers/CursorProvider.ts | 97 +++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 16fd5d8b887..0b70b7c9940 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -4,6 +4,9 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { buildCursorCapabilitiesFromConfigOptions, getCursorModelCapabilities, + getCursorParameterizedModelPickerUnsupportedMessage, + parseCursorCliConfigChannel, + parseCursorVersionDate, resolveCursorAcpBaseModelId, resolveCursorAcpConfigUpdates, resolveCursorAgentModel, @@ -160,6 +163,48 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { }); }); +describe("Cursor parameterized model picker preview gating", () => { + it("parses Cursor CLI version dates from build versions", () => { + expect(parseCursorVersionDate("2026.04.08-c4e73a3")).toBe(20260408); + expect(parseCursorVersionDate("2026.04.09")).toBe(20260409); + expect(parseCursorVersionDate("not-a-version")).toBeUndefined(); + }); + + it("parses the Cursor CLI channel from cli-config.json", () => { + expect(parseCursorCliConfigChannel('{ "channel": "lab" }')).toBe("lab"); + expect(parseCursorCliConfigChannel('{ "channel": "stable" }')).toBe("stable"); + expect(parseCursorCliConfigChannel('{ "version": 1 }')).toBeUndefined(); + expect(parseCursorCliConfigChannel("not-json")).toBeUndefined(); + }); + + it("returns no warning when the preview requirements are met", () => { + expect( + getCursorParameterizedModelPickerUnsupportedMessage({ + version: "2026.04.08-c4e73a3", + channel: "lab", + }), + ).toBeUndefined(); + }); + + it("explains when the Cursor Agent version is too old", () => { + expect( + getCursorParameterizedModelPickerUnsupportedMessage({ + version: "2026.04.07-c4e73a3", + channel: "lab", + }), + ).toContain("too old"); + }); + + it("explains when the Cursor Agent channel is not lab", () => { + expect( + getCursorParameterizedModelPickerUnsupportedMessage({ + version: "2026.04.08-c4e73a3", + channel: "stable", + }), + ).toContain("lab channel"); + }); +}); + describe("resolveCursorAcpBaseModelId", () => { it("drops parameterized ACP traits and preserves base model ids", () => { expect(resolveCursorAcpBaseModelId("gpt-5.4[reasoning=medium,context=272k]")).toBe("gpt-5.4"); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index fef1c8c5e8a..33cdf9a47c4 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1,3 +1,7 @@ +import * as nodeFs from "node:fs"; +import * as nodeOs from "node:os"; +import * as nodePath from "node:path"; + import type { CursorModelOptions, CursorSettings, @@ -186,6 +190,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ]; const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { _meta: { parameterizedModelPicker: true, @@ -657,6 +662,75 @@ export interface CursorAboutResult { readonly message?: string; } +export function parseCursorVersionDate(version: string | null | undefined): number | undefined { + const match = version?.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})(?:\b|-|$)/); + if (!match) { + return undefined; + } + const [, year, month, day] = match; + return Number(`${year}${month}${day}`); +} + +export function parseCursorCliConfigChannel(raw: string): string | undefined { + try { + const parsed = JSON.parse(raw) as unknown; + if ( + typeof parsed === "object" && + parsed !== null && + "channel" in parsed && + typeof parsed.channel === "string" + ) { + const channel = parsed.channel.trim().toLowerCase(); + return channel.length > 0 ? channel : undefined; + } + } catch { + return undefined; + } + return undefined; +} + +function readCursorCliConfigChannel(): string | undefined { + try { + const configPath = nodePath.join(nodeOs.homedir(), ".cursor", "cli-config.json"); + return parseCursorCliConfigChannel(nodeFs.readFileSync(configPath, "utf8")); + } catch { + return undefined; + } +} + +export function getCursorParameterizedModelPickerUnsupportedMessage(input: { + readonly version: string | null | undefined; + readonly channel: string | null | undefined; +}): string | undefined { + const reasons: Array = []; + const versionDate = parseCursorVersionDate(input.version); + if ( + versionDate !== undefined && + versionDate < CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE + ) { + reasons.push( + `Cursor Agent CLI version ${input.version} is too old for Cursor ACP parameterized model picker`, + ); + } + + const normalizedChannel = input.channel?.trim().toLowerCase(); + if ( + normalizedChannel !== undefined && + normalizedChannel.length > 0 && + normalizedChannel !== "lab" + ) { + reasons.push( + `Cursor Agent CLI channel is ${JSON.stringify(input.channel)}, but parameterized model picker is only available on the lab channel`, + ); + } + + if (reasons.length === 0) { + return undefined; + } + + return `${reasons.join(". ")}. Run \`agent set-channel lab && agent update\` and use Cursor Agent CLI 2026.04.08 or newer.`; +} + /** * Parse the output of `agent about` to extract version and authentication * status in a single probe. @@ -830,6 +904,29 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( } const parsed = parseCursorAboutOutput(aboutProbe.success.value); + const parameterizedModelPickerUnsupportedMessage = + getCursorParameterizedModelPickerUnsupportedMessage({ + version: parsed.version, + channel: readCursorCliConfigChannel(), + }); + if (parameterizedModelPickerUnsupportedMessage) { + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: parsed.version, + status: "error", + auth: parsed.auth, + message: + parsed.auth.status === "unauthenticated" && parsed.message + ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}` + : parameterizedModelPickerUnsupportedMessage, + }, + }); + } let discoveredModels = Option.none>(); if (parsed.auth.status !== "unauthenticated") { discoveredModels = yield* discoverCursorModelsViaAcp(cursorSettings).pipe( From 6cbf6923130d06c79362b2ed38b12db469301415 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Apr 2026 02:08:08 -0700 Subject: [PATCH 46/82] Support JSON Cursor about output - Parse `cursor about --format json` results - Fall back to plain `about` when JSON formatting is unsupported - Preserve subscription metadata in provider auth state --- .../provider/Layers/CursorProvider.test.ts | 67 ++++++++ .../src/provider/Layers/CursorProvider.ts | 154 +++++++++++++++++- 2 files changed, 219 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 0b70b7c9940..7f1e554e904 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -5,6 +5,7 @@ import { buildCursorCapabilitiesFromConfigOptions, getCursorModelCapabilities, getCursorParameterizedModelPickerUnsupportedMessage, + parseCursorAboutOutput, parseCursorCliConfigChannel, parseCursorVersionDate, resolveCursorAcpBaseModelId, @@ -163,6 +164,72 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { }); }); +describe("parseCursorAboutOutput", () => { + it("parses json about output and forwards subscription metadata", () => { + expect( + parseCursorAboutOutput({ + code: 0, + stdout: JSON.stringify({ + cliVersion: "2026.04.09-f2b0fcd", + subscriptionTier: "Team", + userEmail: "jmarminge@gmail.com", + }), + stderr: "", + }), + ).toEqual({ + version: "2026.04.09-f2b0fcd", + status: "ready", + auth: { + status: "authenticated", + type: "Team", + label: "Cursor Team Subscription", + }, + }); + }); + + it("treats json about output with a logged-out email as unauthenticated", () => { + expect( + parseCursorAboutOutput({ + code: 0, + stdout: JSON.stringify({ + cliVersion: "2026.04.09-f2b0fcd", + subscriptionTier: "Team", + userEmail: "Not logged in", + }), + stderr: "", + }), + ).toEqual({ + version: "2026.04.09-f2b0fcd", + status: "error", + auth: { + status: "unauthenticated", + }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }); + }); + + it("treats json about output with a null email as unauthenticated", () => { + expect( + parseCursorAboutOutput({ + code: 0, + stdout: JSON.stringify({ + cliVersion: "2026.04.09-f2b0fcd", + subscriptionTier: null, + userEmail: null, + }), + stderr: "", + }), + ).toEqual({ + version: "2026.04.09-f2b0fcd", + status: "error", + auth: { + status: "unauthenticated", + }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }); + }); +}); + describe("Cursor parameterized model picker preview gating", () => { it("parses Cursor CLI version dates from build versions", () => { expect(parseCursorVersionDate("2026.04.08-c4e73a3")).toBe(20260408); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 33cdf9a47c4..8318b8e7906 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -658,10 +658,16 @@ function extractAboutField(plain: string, key: string): string | undefined { export interface CursorAboutResult { readonly version: string | null; readonly status: Exclude; - readonly auth: Pick; + readonly auth: ServerProviderAuth; readonly message?: string; } +interface CursorAboutJsonPayload { + readonly cliVersion?: unknown; + readonly subscriptionTier?: unknown; + readonly userEmail?: unknown; +} + export function parseCursorVersionDate(version: string | null | undefined): number | undefined { const match = version?.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})(?:\b|-|$)/); if (!match) { @@ -689,6 +695,77 @@ export function parseCursorCliConfigChannel(raw: string): string | undefined { return undefined; } +function toTitleCaseWords(value: string): string { + return value + .split(/[\s_-]+/g) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(" "); +} + +function cursorSubscriptionLabel(subscriptionType: string | undefined): string | undefined { + const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); + if (!normalized) return undefined; + + switch (normalized) { + case "team": + return "Team"; + case "pro": + return "Pro"; + case "free": + return "Free"; + case "business": + return "Business"; + case "enterprise": + return "Enterprise"; + default: + return toTitleCaseWords(subscriptionType!); + } +} + +function cursorAuthMetadata( + subscriptionType: string | undefined, +): Pick | undefined { + if (!subscriptionType) { + return undefined; + } + const subscriptionLabel = cursorSubscriptionLabel(subscriptionType); + return { + type: subscriptionType, + label: `Cursor ${subscriptionLabel ?? toTitleCaseWords(subscriptionType)} Subscription`, + }; +} + +function parseCursorAboutJsonPayload(raw: string): CursorAboutJsonPayload | undefined { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) { + return undefined; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + return parsed as CursorAboutJsonPayload; + } catch { + return undefined; + } +} + +function hasOwn(record: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + return ( + lowerOutput.includes("unknown option '--format'") || + lowerOutput.includes("unexpected argument '--format'") || + lowerOutput.includes("unrecognized option '--format'") || + lowerOutput.includes("unknown argument '--format'") + ); +} + function readCursorCliConfigChannel(): string | undefined { try { const configPath = nodePath.join(nodeOs.homedir(), ".cursor", "cli-config.json"); @@ -752,6 +829,71 @@ export function getCursorParameterizedModelPickerUnsupportedMessage(input: { * ``` */ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult { + const jsonPayload = parseCursorAboutJsonPayload(result.stdout); + if (jsonPayload) { + const version = + typeof jsonPayload.cliVersion === "string" ? jsonPayload.cliVersion.trim() : null; + const hasUserEmailField = hasOwn(jsonPayload, "userEmail"); + const userEmail = + typeof jsonPayload.userEmail === "string" ? jsonPayload.userEmail.trim() : undefined; + const subscriptionType = + typeof jsonPayload.subscriptionTier === "string" + ? jsonPayload.subscriptionTier.trim() + : undefined; + const authMetadata = cursorAuthMetadata(subscriptionType); + + if (hasUserEmailField && jsonPayload.userEmail == null) { + return { + version, + status: "error", + auth: { status: "unauthenticated" }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }; + } + + if (!userEmail) { + if (result.code === 0) { + return { + version, + status: "ready", + auth: { + status: "unknown", + ...authMetadata, + }, + }; + } + return { + version, + status: "warning", + auth: { status: "unknown" }, + message: "Could not verify Cursor Agent authentication status.", + }; + } + + const lowerEmail = userEmail.toLowerCase(); + if ( + lowerEmail === "not logged in" || + lowerEmail.includes("login required") || + lowerEmail.includes("authentication required") + ) { + return { + version, + status: "error", + auth: { status: "unauthenticated" }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }; + } + + return { + version, + status: "ready", + auth: { + status: "authenticated", + ...authMetadata, + }, + }; + } + const combined = `${result.stdout}\n${result.stderr}`; const lowerOutput = combined.toLowerCase(); @@ -829,6 +971,14 @@ const runCursorCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); +const runCursorAboutCommand = Effect.gen(function* () { + const jsonResult = yield* runCursorCommand(["about", "--format", "json"]); + if (!isCursorAboutJsonFormatUnsupported(jsonResult)) { + return jsonResult; + } + return yield* runCursorCommand(["about"]); +}); + export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( function* (): Effect.fn.Return< ServerProvider, @@ -863,7 +1013,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( } // Single `agent about` probe: returns version + auth status in one call. - const aboutProbe = yield* runCursorCommand(["about"]).pipe( + const aboutProbe = yield* runCursorAboutCommand.pipe( Effect.timeoutOption(ABOUT_TIMEOUT_MS), Effect.result, ); From 2a23bd218277715a276cdfa13481f5b3e5f3d9c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 13:52:17 -0700 Subject: [PATCH 47/82] Remove obsolete Effect patches --- package.json | 3 - patches/effect@4.0.0-beta.41.patch | 108 ----------------------------- patches/effect@4.0.0-beta.42.patch | 96 ------------------------- 3 files changed, 207 deletions(-) delete mode 100644 patches/effect@4.0.0-beta.41.patch delete mode 100644 patches/effect@4.0.0-beta.42.patch diff --git a/package.json b/package.json index d1c6eb4cd9a..20bdd342170 100644 --- a/package.json +++ b/package.json @@ -82,9 +82,6 @@ "apps/web/public" ] }, - "patchedDependencies": { - "effect@4.0.0-beta.42": "patches/effect@4.0.0-beta.42.patch" - }, "trustedDependencies": [ "node-pty" ] diff --git a/patches/effect@4.0.0-beta.41.patch b/patches/effect@4.0.0-beta.41.patch deleted file mode 100644 index f7296bc10cb..00000000000 --- a/patches/effect@4.0.0-beta.41.patch +++ /dev/null @@ -1,108 +0,0 @@ -diff --git a/src/SchemaRepresentation.ts b/src/SchemaRepresentation.ts -index e1a87e2b..a7797678 100644 ---- a/src/SchemaRepresentation.ts -+++ b/src/SchemaRepresentation.ts -@@ -3007,7 +3007,14 @@ export function fromJsonSchemaMultiDocument(document: JsonSchema.MultiDocument<" - } - } - -- let out = on(js) -+ const hasAnyOf = Array.isArray(js.anyOf) -+ const hasOneOf = Array.isArray(js.oneOf) -+ const base = -+ hasAnyOf || hasOneOf -+ ? ({ ...js, anyOf: undefined, oneOf: undefined } as JsonSchema.JsonSchema) -+ : js -+ -+ let out = on(base) - - const annotations = collectAnnotations(js) - if (annotations !== undefined) { -@@ -3018,6 +3025,14 @@ export function fromJsonSchemaMultiDocument(document: JsonSchema.MultiDocument<" - return js.allOf.reduce((acc, curr) => combine(acc, recur(curr)), out) - } - -+ if (hasAnyOf) { -+ out = combine({ _tag: "Union", types: js.anyOf.map((type) => recur(type)), mode: "anyOf" }, out) -+ } -+ -+ if (hasOneOf) { -+ out = combine({ _tag: "Union", types: js.oneOf.map((type) => recur(type)), mode: "oneOf" }, out) -+ } -+ - return out - } - -@@ -3054,12 +3069,7 @@ export function fromJsonSchemaMultiDocument(document: JsonSchema.MultiDocument<" - } else { - return { _tag: "Union", types, mode: "anyOf" } - } -- } else if (Array.isArray(js.anyOf)) { -- return { _tag: "Union", types: js.anyOf.map((type) => recur(type)), mode: "anyOf" } -- } else if (Array.isArray(js.oneOf)) { -- return { _tag: "Union", types: js.oneOf.map((type) => recur(type)), mode: "oneOf" } - } -- - const type = isType(js.type) ? js.type : getType(js) - if (type !== undefined) { - switch (type) { -diff --git a/dist/SchemaRepresentation.js b/dist/SchemaRepresentation.js -index 0d3f01d7..c3672558 100644 ---- a/dist/SchemaRepresentation.js -+++ b/dist/SchemaRepresentation.js -@@ -2042,7 +2042,14 @@ export function fromJsonSchemaMultiDocument(document, options) { - js = {}; - } - } -- let out = on(js); -+ const hasAnyOf = Array.isArray(js.anyOf); -+ const hasOneOf = Array.isArray(js.oneOf); -+ const base = hasAnyOf || hasOneOf ? { -+ ...js, -+ anyOf: undefined, -+ oneOf: undefined -+ } : js; -+ let out = on(base); - const annotations = collectAnnotations(js); - if (annotations !== undefined) { - out = combine(out, { -@@ -2053,6 +2060,20 @@ export function fromJsonSchemaMultiDocument(document, options) { - if (Array.isArray(js.allOf)) { - return js.allOf.reduce((acc, curr) => combine(acc, recur(curr)), out); - } -+ if (hasAnyOf) { -+ out = combine({ -+ _tag: "Union", -+ types: js.anyOf.map(type => recur(type)), -+ mode: "anyOf" -+ }, out); -+ } -+ if (hasOneOf) { -+ out = combine({ -+ _tag: "Union", -+ types: js.oneOf.map(type => recur(type)), -+ mode: "oneOf" -+ }, out); -+ } - return out; - } - function on(js) { -@@ -2105,18 +2126,6 @@ export function fromJsonSchemaMultiDocument(document, options) { - mode: "anyOf" - }; - } -- } else if (Array.isArray(js.anyOf)) { -- return { -- _tag: "Union", -- types: js.anyOf.map(type => recur(type)), -- mode: "anyOf" -- }; -- } else if (Array.isArray(js.oneOf)) { -- return { -- _tag: "Union", -- types: js.oneOf.map(type => recur(type)), -- mode: "oneOf" -- }; - } - const type = isType(js.type) ? js.type : getType(js); - if (type !== undefined) { diff --git a/patches/effect@4.0.0-beta.42.patch b/patches/effect@4.0.0-beta.42.patch deleted file mode 100644 index ec5acf2c19d..00000000000 --- a/patches/effect@4.0.0-beta.42.patch +++ /dev/null @@ -1,96 +0,0 @@ -diff --git a/dist/unstable/rpc/RpcSerialization.js b/dist/unstable/rpc/RpcSerialization.js -index 26dc4f2608580af60306a99243a9abf2b3581640..7c25eeb940428fc2844c986cbb98b989518e7f5a 100644 ---- a/dist/unstable/rpc/RpcSerialization.js -+++ b/dist/unstable/rpc/RpcSerialization.js -@@ -139,7 +139,7 @@ function decodeJsonRpcRaw(decoded, batches) { - } - function decodeJsonRpcMessage(decoded) { - if ("method" in decoded) { -- if (!decoded.id && decoded.method.startsWith("@effect/rpc/")) { -+ if (decoded.id == null && decoded.method.startsWith("@effect/rpc/")) { - const tag = decoded.method.slice("@effect/rpc/".length); - const requestId = decoded.params?.requestId; - return requestId ? { -@@ -151,7 +151,7 @@ function decodeJsonRpcMessage(decoded) { - } - return { - _tag: "Request", -- id: decoded.id ? String(decoded.id) : "", -+ id: decoded.id != null ? String(decoded.id) : "", - tag: decoded.method, - payload: decoded.params ?? null, - headers: decoded.headers ?? [], -@@ -241,7 +241,7 @@ function encodeJsonRpcMessage(response) { - jsonrpc: "2.0", - method: response.tag, - params: response.payload, -- id: response.id && Number(response.id), -+ id: response.id !== "" ? Number(response.id) : undefined, - headers: response.headers, - traceId: response.traceId, - spanId: response.spanId, -@@ -271,14 +271,14 @@ function encodeJsonRpcMessage(response) { - if (response.exit._tag === "Success") { - return { - jsonrpc: "2.0", -- id: response.requestId ? Number(response.requestId) : undefined, -+ id: response.requestId !== "" ? Number(response.requestId) : undefined, - result: response.exit.value - }; - } - const error = response.exit.cause.find(failure => failure._tag === "Fail"); - return { - jsonrpc: "2.0", -- id: response.requestId ? Number(response.requestId) : undefined, -+ id: response.requestId !== "" ? Number(response.requestId) : undefined, - error: response.exit._tag === "Failure" ? { - _tag: "Cause", - code: error && Predicate.hasProperty(error, "code") ? Number(error.code) : 0, -diff --git a/src/unstable/rpc/RpcSerialization.ts b/src/unstable/rpc/RpcSerialization.ts -index 319effeabbc94237cd1a11799ac5d9bbd39d0170..66e82004de888b38f19a792886b461daa1789c55 100644 ---- a/src/unstable/rpc/RpcSerialization.ts -+++ b/src/unstable/rpc/RpcSerialization.ts -@@ -180,7 +180,7 @@ function decodeJsonRpcRaw( - - function decodeJsonRpcMessage(decoded: JsonRpcMessage): RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded { - if ("method" in decoded) { -- if (!decoded.id && decoded.method.startsWith("@effect/rpc/")) { -+ if (decoded.id == null && decoded.method.startsWith("@effect/rpc/")) { - const tag = decoded.method.slice("@effect/rpc/".length) as - | RpcMessage.FromServerEncoded["_tag"] - | Exclude -@@ -194,7 +194,7 @@ function decodeJsonRpcMessage(decoded: JsonRpcMessage): RpcMessage.FromClientEnc - } - return { - _tag: "Request", -- id: decoded.id ? String(decoded.id) : "", -+ id: decoded.id != null ? String(decoded.id) : "", - tag: decoded.method, - payload: decoded.params ?? null, - headers: decoded.headers ?? [], -@@ -308,7 +308,7 @@ function encodeJsonRpcMessage(response: RpcMessage.FromServerEncoded | RpcMessag - jsonrpc: "2.0", - method: response.tag, - params: response.payload, -- id: response.id && Number(response.id), -+ id: response.id !== "" ? Number(response.id) : undefined, - headers: response.headers, - traceId: response.traceId, - spanId: response.spanId, -@@ -335,14 +335,14 @@ function encodeJsonRpcMessage(response: RpcMessage.FromServerEncoded | RpcMessag - if (response.exit._tag === "Success") { - return { - jsonrpc: "2.0", -- id: response.requestId ? Number(response.requestId) : undefined, -+ id: response.requestId !== "" ? Number(response.requestId) : undefined, - result: response.exit.value - } as any - } - const error = response.exit.cause.find((failure) => failure._tag === "Fail") - return { - jsonrpc: "2.0", -- id: response.requestId ? Number(response.requestId) : undefined, -+ id: response.requestId !== "" ? Number(response.requestId) : undefined, - error: response.exit._tag === "Failure" ? - { - _tag: "Cause", From 50d2528e3085c2536e2ca56cfa0dbc1d26c0cce7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 14:04:39 -0700 Subject: [PATCH 48/82] Update Cursor provider test expectations --- .../server/src/provider/Layers/CursorAdapter.test.ts | 12 ++++++++---- .../src/provider/Layers/ProviderRegistry.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 5dfdf74db2a..a15932f3832 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -844,11 +844,15 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ); assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); assert.equal((setConfigRequests[0]?.params as Record)?.value, "composer-2"); - const lastSetConfig = setConfigRequests[setConfigRequests.length - 1]; - assert.equal( - (lastSetConfig?.params as Record)?.value, - "composer-2[fast=true]", + + const fastConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "fast", ); + assert.isAbove(fastConfigRequests.length, 0, "should apply fast mode as a separate config"); + const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1]; + assert.equal((lastFastConfig?.params as Record)?.value, "true"); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 802ec281af5..2f78ccbc0d1 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -531,6 +531,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( } return { stdout: "", stderr: "spawn ENOENT", code: 1 }; } + if (joined === "about --format json") { + return { + stdout: JSON.stringify({ + cliVersion: "2026.04.09-f2b0fcd", + userEmail: null, + }), + stderr: "", + code: 0, + }; + } if (joined === "about") { return { stdout: "", stderr: "spawn ENOENT", code: 1 }; } From 543e4960f53e1ef4b1587a51aa5373b1e92b1918 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 14:58:39 -0700 Subject: [PATCH 49/82] Preserve explicit Cursor option resets - Keep false/explicit defaults in draft and dispatch state - Allow Cursor fast mode and thinking to be cleared on later turns - Add coverage for Cursor option reset behavior --- .../src/provider/Layers/CursorAdapter.test.ts | 51 +++++++++++++++ .../provider/Layers/CursorProvider.test.ts | 8 +++ .../src/provider/Layers/CursorProvider.ts | 4 +- .../chat/composerProviderRegistry.test.tsx | 62 ++++++++++++++++++- apps/web/src/composerDraftStore.test.ts | 29 ++++++++- apps/web/src/composerDraftStore.ts | 18 ++++-- apps/web/src/providerModels.ts | 17 ++--- 7 files changed, 173 insertions(+), 16 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index a15932f3832..1ac85b83bad 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -857,4 +857,55 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect("clears prior fast mode in-session when the next turn sets fastMode: false", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-fast-mode-reset"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "composer-2" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn with fast mode", + attachments: [], + modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "second turn without fast mode", + attachments: [], + modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: false } }, + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const fastConfigRequests = requests.filter( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "fast", + ); + assert.isAtLeast(fastConfigRequests.length, 2, "should set fast mode on and then off"); + + const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1]; + assert.equal((lastFastConfig?.params as Record)?.value, "false"); + + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 7f1e554e904..b363b19ebf6 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -302,6 +302,14 @@ describe("resolveCursorAcpConfigUpdates", () => { }), ).toEqual([{ configId: "thinking", value: false }]); }); + + it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => { + expect( + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { + fastMode: false, + }), + ).toEqual([{ configId: "fast", value: "false" }]); + }); }); describe("resolveCursorAgentModel", () => { diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 8a45084f3a2..a7cfaff7a00 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -449,8 +449,8 @@ export function resolveCursorAcpConfigUpdates( const fastOption = configOptions.find( (option) => option.category === "model_config" && isCursorFastConfigOption(option), ); - if (fastOption && modelOptions?.fastMode === true) { - const value = findCursorBooleanConfigValue(fastOption, true); + if (fastOption && typeof modelOptions?.fastMode === "boolean") { + const value = findCursorBooleanConfigValue(fastOption, modelOptions.fastMode); if (value !== undefined) { updates.push({ configId: fastOption.id, value }); } diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 1104feca6ba..208eabd647f 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -88,6 +88,22 @@ const CURSOR_MODELS: ReadonlyArray = [ promptInjectedEffortLevels: [], }, }, + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, ]; const CLAUDE_MODELS: ReadonlyArray = [ @@ -405,7 +421,7 @@ describe("getComposerProviderState", () => { }); }); - it("drops default Cursor reasoning from dispatch options", () => { + it("preserves default Cursor reasoning in dispatch options so prior overrides can be cleared", () => { const state = getComposerProviderState({ provider: "cursor", model: "gpt-5.3-codex", @@ -419,7 +435,7 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "cursor", promptEffort: "medium", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { reasoning: "medium" }, }); }); @@ -445,6 +461,48 @@ describe("getComposerProviderState", () => { }); }); + it("preserves explicit default Cursor reasoning so deepMerge can clear a prior non-default", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "gpt-5.4", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { reasoning: "medium" }, + }, + }); + + expect(state.modelOptionsForDispatch).toHaveProperty("reasoning", "medium"); + }); + + it("preserves explicit Cursor fastMode: false so deepMerge can overwrite a prior true", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "composer-2", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { fastMode: false }, + }, + }); + + expect(state.modelOptionsForDispatch).toHaveProperty("fastMode", false); + }); + + it("preserves explicit Cursor thinking: true so deepMerge can overwrite a prior false", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "claude-opus-4-6", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { thinking: true }, + }, + }); + + expect(state.modelOptionsForDispatch).toHaveProperty("thinking", true); + }); + it("preserves Claude default effort explicitly in dispatch options", () => { const state = getComposerProviderState({ provider: "claudeAgent", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 2169bbf8584..16f348d3127 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -91,7 +91,7 @@ function resetComposerDraftStore() { } function modelSelection( - provider: "codex" | "claudeAgent", + provider: "codex" | "claudeAgent" | "cursor", model: string, options?: ModelSelection["options"], ): ModelSelection { @@ -959,6 +959,33 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("keeps explicit Cursor reset overrides on the selection", () => { + const store = useComposerDraftStore.getState(); + + store.setModelSelection( + threadRef, + modelSelection("cursor", "claude-opus-4-6", { + reasoning: "xhigh", + fastMode: true, + thinking: false, + }), + ); + + store.setProviderModelOptions(threadRef, "cursor", { + reasoning: "medium", + fastMode: false, + thinking: true, + }); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual( + modelSelection("cursor", "claude-opus-4-6", { + reasoning: "medium", + fastMode: false, + thinking: true, + }), + ); + }); + it("updates only the draft when sticky persistence is omitted", () => { const store = useComposerDraftStore.getState(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e5f1666ca98..f70de2f0d16 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -626,8 +626,18 @@ function normalizeProviderModelOptions( (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorReasoningRaw) ? (cursorReasoningRaw as CursorReasoningOption) : undefined; - const cursorFastMode = cursorCandidate?.fastMode === true; - const cursorThinkingFalse = cursorCandidate?.thinking === false; + const cursorFastMode = + cursorCandidate?.fastMode === true + ? true + : cursorCandidate?.fastMode === false + ? false + : undefined; + const cursorThinking = + cursorCandidate?.thinking === true + ? true + : cursorCandidate?.thinking === false + ? false + : undefined; const cursorContextWindow = typeof cursorCandidate?.contextWindow === "string" && cursorCandidate.contextWindow.length > 0 ? cursorCandidate.contextWindow @@ -637,8 +647,8 @@ function normalizeProviderModelOptions( cursorCandidate !== null ? { ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), - ...(cursorFastMode ? { fastMode: true } : {}), - ...(cursorThinkingFalse ? { thinking: false } : {}), + ...(cursorFastMode !== undefined ? { fastMode: cursorFastMode } : {}), + ...(cursorThinking !== undefined ? { thinking: cursorThinking } : {}), ...(cursorContextWindow !== undefined ? { contextWindow: cursorContextWindow } : {}), } : undefined; diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 4673ddfd65c..f4032e533d4 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -7,7 +7,6 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { - getDefaultEffort, hasEffortLevel, normalizeModelSlug, resolveContextWindow, @@ -79,20 +78,24 @@ export function normalizeCursorModelOptionsWithCapabilities( caps: ModelCapabilities, modelOptions: CursorModelOptions | null | undefined, ): CursorModelOptions | undefined { - const defaultEffort = getDefaultEffort(caps); const reasoning = trimOrNull(modelOptions?.reasoning); const reasoningValue = - reasoning && hasEffortLevel(caps, reasoning) && reasoning !== defaultEffort + reasoning && hasEffortLevel(caps, reasoning) ? (reasoning as CursorModelOptions["reasoning"]) : undefined; - const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const fastMode = + caps.supportsFastMode && typeof modelOptions?.fastMode === "boolean" + ? modelOptions.fastMode + : undefined; const thinking = - caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + caps.supportsThinkingToggle && typeof modelOptions?.thinking === "boolean" + ? modelOptions.thinking + : undefined; const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); const nextOptions: CursorModelOptions = { ...(reasoningValue ? { reasoning: reasoningValue } : {}), - ...(fastMode ? { fastMode: true } : {}), - ...(thinking === false ? { thinking: false } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + ...(thinking !== undefined ? { thinking } : {}), ...(contextWindow ? { contextWindow } : {}), }; return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; From 0c1793a96784db5e38a7f2b70a0384d031c57510 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 23:51:29 -0700 Subject: [PATCH 50/82] Refine provider discovery and Cursor ACP model handling - Seed providers with initial disabled/loading snapshots - Discover Cursor ACP models from session config options - Update Traits picker to use the new Cursor model trait flow - Add probe script and tests for Cursor provider behavior --- .../cursor-acp-model-mismatch-probe.ts | 435 ++++++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 43 ++ .../src/provider/Layers/CodexProvider.ts | 43 ++ .../provider/Layers/CursorProvider.test.ts | 208 +++++--- .../src/provider/Layers/CursorProvider.ts | 486 ++++++------------ .../provider/Layers/ProviderRegistry.test.ts | 13 +- .../src/provider/Layers/ProviderRegistry.ts | 120 +++-- .../src/provider/acp/CursorAcpSupport.test.ts | 8 +- .../makeManagedServerProvider.test.ts | 142 +++++ .../src/provider/makeManagedServerProvider.ts | 97 +++- .../components/chat/TraitsPicker.browser.tsx | 152 ++++++ apps/web/src/components/chat/TraitsPicker.tsx | 36 +- 12 files changed, 1330 insertions(+), 453 deletions(-) create mode 100644 apps/server/scripts/cursor-acp-model-mismatch-probe.ts create mode 100644 apps/server/src/provider/makeManagedServerProvider.test.ts diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts new file mode 100644 index 00000000000..0f81b3b088b --- /dev/null +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -0,0 +1,435 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import process from "node:process"; +import readline from "node:readline"; + +type JsonPrimitive = null | boolean | number | string; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +type JsonRpcId = number | string; + +type JsonRpcMessage = { + jsonrpc?: string; + id?: JsonRpcId; + method?: string; + params?: JsonValue; + result?: JsonValue; + error?: JsonValue; + headers?: JsonValue; +}; + +type SelectLeafOption = { + value: string; + label?: string; + name?: string; +}; + +type SelectGroupOption = { + label?: string; + name?: string; + options: SelectLeafOption[]; +}; + +type SessionConfigOption = { + id: string; + name?: string; + category?: string; + type?: string; + options?: Array; +}; + +type SessionNewResult = { + sessionId: string; + configOptions?: SessionConfigOption[]; +}; + +type SetConfigResult = { + configOptions?: SessionConfigOption[]; +}; + +type PendingRequest = { + method: string; + resolve: (value: JsonValue | undefined) => void; + reject: (error: Error) => void; +}; + +const targetCwd = process.argv[2] ?? process.cwd(); +const targetModel = process.argv[3] ?? "gpt-5.4"; +const promptText = process.argv[4] ?? "helo"; +const targetReasoning = process.env.CURSOR_REASONING ?? ""; +const targetContext = process.env.CURSOR_CONTEXT ?? ""; +const targetFast = process.env.CURSOR_FAST ?? ""; +const agentBin = process.env.CURSOR_AGENT_BIN ?? "agent"; +const promptWaitMs = Number(process.env.CURSOR_PROMPT_WAIT_MS ?? "4000"); +const requestTimeoutMs = Number(process.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000"); + +function logSection(title: string, value: unknown) { + process.stdout.write(`\n=== ${title} ===\n`); + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function fail(message: string): never { + throw new Error(message); +} + +function asString(value: JsonValue | undefined): string | null { + return typeof value === "string" ? value : null; +} + +function flattenSelectValues(option: SessionConfigOption | undefined): string[] { + if (!option || option.type !== "select" || !Array.isArray(option.options)) { + return []; + } + + const values: string[] = []; + for (const entry of option.options) { + if (!entry || typeof entry !== "object") { + continue; + } + if ("value" in entry && typeof entry.value === "string") { + values.push(entry.value); + continue; + } + if ("options" in entry && Array.isArray(entry.options)) { + for (const nested of entry.options) { + if (nested && typeof nested === "object" && typeof nested.value === "string") { + values.push(nested.value); + } + } + } + } + return values; +} + +function findConfigOption( + configOptions: SessionConfigOption[], + predicate: (option: SessionConfigOption) => boolean, +): SessionConfigOption | undefined { + return configOptions.find(predicate); +} + +function matchesKeyword(option: SessionConfigOption, keyword: string): boolean { + const haystack = `${option.id} ${option.name ?? ""}`.toLowerCase(); + return haystack.includes(keyword.toLowerCase()); +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +class JsonRpcChild { + readonly child: ChildProcessWithoutNullStreams; + readonly pending = new Map(); + nextId = 1; + closed = false; + + constructor(bin: string, args: string[], cwd: string) { + this.child = spawn(bin, args, { + cwd, + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }); + + this.child.on("exit", (code, signal) => { + this.closed = true; + const detail = `ACP process exited (code=${String(code)}, signal=${String(signal)})`; + for (const pending of this.pending.values()) { + pending.reject(new Error(`${detail} while waiting for ${pending.method}`)); + } + this.pending.clear(); + }); + + this.child.on("error", (error) => { + this.closed = true; + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + }); + + const stdout = readline.createInterface({ input: this.child.stdout }); + stdout.on("line", (line) => { + void this.handleStdoutLine(line); + }); + + const stderr = readline.createInterface({ input: this.child.stderr }); + stderr.on("line", (line) => { + process.stdout.write(`[stderr] ${line}\n`); + }); + } + + write(message: JsonRpcMessage) { + if (this.closed) { + fail("ACP process is already closed."); + } + const payload = JSON.stringify({ + jsonrpc: "2.0", + headers: [], + ...message, + }); + process.stdout.write(`>>> ${payload}\n`); + this.child.stdin.write(`${payload}\n`); + } + + async request(method: string, params: JsonValue, timeoutMs = requestTimeoutMs) { + const id = this.nextId++; + + const responsePromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Timed out waiting for ${method} response after ${timeoutMs}ms.`)); + }, timeoutMs); + + this.pending.set(id, { + method, + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + }); + + this.write({ + id, + method, + params, + }); + + return responsePromise; + } + + notify(method: string, params: JsonValue) { + this.write({ + method, + params, + }); + } + + respond(id: JsonRpcId, result: JsonValue) { + this.write({ + id, + result, + }); + } + + respondError(id: JsonRpcId, code: number, message: string) { + this.write({ + id, + error: { + code, + message, + }, + }); + } + + async handleStdoutLine(line: string) { + if (line.trim().length === 0) { + return; + } + + process.stdout.write(`<<< ${line}\n`); + + let message: JsonRpcMessage; + try { + message = JSON.parse(line) as JsonRpcMessage; + } catch (error) { + process.stdout.write(`[parse-error] ${(error as Error).message}\n`); + return; + } + + if (typeof message.id !== "undefined" && !message.method) { + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + this.pending.delete(message.id); + if (typeof message.error !== "undefined") { + pending.reject( + new Error(`RPC ${pending.method} failed: ${JSON.stringify(message.error, null, 2)}`), + ); + return; + } + pending.resolve(message.result); + return; + } + + if (message.method === "session/request_permission" && typeof message.id !== "undefined") { + this.respond(message.id, { + outcome: { + outcome: "selected", + optionId: "allow", + }, + }); + return; + } + + if (typeof message.id !== "undefined" && message.id !== "") { + this.respondError( + message.id, + -32601, + `Unhandled server request: ${message.method ?? "unknown"}`, + ); + } + } + + async close() { + if (this.closed) { + return; + } + this.child.kill("SIGTERM"); + await sleep(250); + if (!this.closed) { + this.child.kill("SIGKILL"); + } + } +} + +async function setSelectOptionIfAdvertised( + rpc: JsonRpcChild, + sessionId: string, + configOptions: SessionConfigOption[], + predicate: (option: SessionConfigOption) => boolean, + value: string, + label: string, +) { + if (value.length === 0) { + return configOptions; + } + + const option = findConfigOption(configOptions, predicate); + const values = flattenSelectValues(option); + if (!option || !values.includes(value)) { + logSection(`SKIP_${label}`, { + requestedValue: value, + availableValues: values, + }); + return configOptions; + } + + const response = (await rpc.request("session/set_config_option", { + sessionId, + configId: option.id, + value, + })) as SetConfigResult; + + logSection(`SET_${label}_RESPONSE`, response); + return response.configOptions ?? configOptions; +} + +async function main() { + const rpc = new JsonRpcChild(agentBin, ["acp"], targetCwd); + + try { + const initializeResponse = await rpc.request("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + _meta: { + parameterizedModelPicker: true, + }, + }, + clientInfo: { + name: "cursor-acp-model-mismatch-probe", + version: "0.0.0", + }, + }); + logSection("INITIALIZE_RESPONSE", initializeResponse); + + const authenticateResponse = await rpc.request("authenticate", { + methodId: "cursor_login", + }); + logSection("AUTHENTICATE_RESPONSE", authenticateResponse); + + const sessionResponse = (await rpc.request("session/new", { + cwd: targetCwd, + mcpServers: [], + })) as SessionNewResult; + logSection("SESSION_NEW_RESPONSE", sessionResponse); + + const sessionId = asString(sessionResponse.sessionId); + if (!sessionId) { + fail("session/new did not return a sessionId."); + } + + let configOptions = sessionResponse.configOptions ?? []; + const modelConfig = findConfigOption(configOptions, (option) => option.category === "model"); + const advertisedModels = flattenSelectValues(modelConfig); + logSection("ADVERTISED_MODEL_VALUES", advertisedModels); + + if (!modelConfig || modelConfig.type !== "select") { + fail("Cursor ACP did not expose a select-type model config option."); + } + + if (!advertisedModels.includes(targetModel)) { + fail( + `Cursor ACP did not advertise model ${JSON.stringify(targetModel)}. Advertised values: ${advertisedModels.join(", ")}`, + ); + } + + const setModelResponse = (await rpc.request("session/set_config_option", { + sessionId, + configId: modelConfig.id, + value: targetModel, + })) as SetConfigResult; + logSection("SET_MODEL_RESPONSE", setModelResponse); + + configOptions = setModelResponse.configOptions ?? configOptions; + + configOptions = await setSelectOptionIfAdvertised( + rpc, + sessionId, + configOptions, + (option) => option.category === "thought_level", + targetReasoning, + "REASONING", + ); + + configOptions = await setSelectOptionIfAdvertised( + rpc, + sessionId, + configOptions, + (option) => option.category === "model_config" && matchesKeyword(option, "context"), + targetContext, + "CONTEXT", + ); + + configOptions = await setSelectOptionIfAdvertised( + rpc, + sessionId, + configOptions, + (option) => option.category === "model_config" && matchesKeyword(option, "fast"), + targetFast, + "FAST", + ); + + const promptResponse = await rpc.request("session/prompt", { + sessionId, + prompt: [ + { + type: "text", + text: promptText, + }, + ], + }); + logSection("PROMPT_RESPONSE", promptResponse); + + await sleep(promptWaitMs); + rpc.notify("session/cancel", { sessionId }); + } finally { + await rpc.close(); + } +} + +void main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index a267b34c00e..77b2e9f171f 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -106,6 +106,48 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo ); } +function buildInitialClaudeProviderSnapshot(claudeSettings: ClaudeSettings): ServerProvider { + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); + + if (!claudeSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + slashCommands: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Claude is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + slashCommands: [], + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Claude availability...", + }, + }); +} + export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { return resolveApiModelId(modelSelection); } @@ -703,6 +745,7 @@ export const ClaudeProviderLive = Layer.effect( Stream.map((settings) => settings.providers.claudeAgent), ), haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + buildInitialSnapshot: buildInitialClaudeProviderSnapshot, checkProvider, }); }), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 421621c9699..5f4fb29d7e9 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -178,6 +178,48 @@ export function getCodexModelCapabilities(model: string | null | undefined): Mod ); } +function buildInitialCodexProviderSnapshot(codexSettings: CodexSettings): ServerProvider { + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + codexSettings.customModels, + DEFAULT_CODEX_MODEL_CAPABILITIES, + ); + + if (!codexSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + skills: [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + skills: [], + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Codex CLI availability...", + }, + }); +} + export function parseAuthStatusFromOutput(result: CommandResult): { readonly status: Exclude; readonly auth: Pick; @@ -602,6 +644,7 @@ export const CodexProviderLive = Layer.effect( Stream.map((settings) => settings.providers.codex), ), haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + buildInitialSnapshot: buildInitialCodexProviderSnapshot, checkProvider, }); }), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index b363b19ebf6..1e0d42c2f29 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -3,22 +3,21 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { buildCursorCapabilitiesFromConfigOptions, - getCursorModelCapabilities, + buildCursorDiscoveredModelsFromConfigOptions, + getCursorFallbackModels, getCursorParameterizedModelPickerUnsupportedMessage, parseCursorAboutOutput, parseCursorCliConfigChannel, parseCursorVersionDate, resolveCursorAcpBaseModelId, resolveCursorAcpConfigUpdates, - resolveCursorAgentModel, - resolveCursorAcpModelId, } from "./CursorProvider.ts"; const parameterizedGpt54ConfigOptions = [ { type: "select", - currentValue: "gpt-5.4", - options: [{ name: "GPT-5.4", value: "gpt-5.4" }], + currentValue: "gpt-5.4-medium-fast", + options: [{ name: "GPT-5.4", value: "gpt-5.4-medium-fast" }], category: "model", id: "model", name: "Model", @@ -64,8 +63,8 @@ const parameterizedGpt54ConfigOptions = [ const parameterizedClaudeConfigOptions = [ { type: "select", - currentValue: "claude-opus-4-6", - options: [{ name: "Opus 4.6", value: "claude-opus-4-6" }], + currentValue: "claude-4.6-opus-high-thinking", + options: [{ name: "Opus 4.6", value: "claude-4.6-opus-high-thinking" }], category: "model", id: "model", name: "Model", @@ -91,42 +90,55 @@ const parameterizedClaudeConfigOptions = [ }, ] satisfies ReadonlyArray; -describe("resolveCursorAcpModelId", () => { - it("emits ACP model ids that match explicit Cursor ACP config values", () => { - expect(resolveCursorAcpModelId("composer-2", { fastMode: true })).toBe("composer-2[fast=true]"); - expect(resolveCursorAcpModelId("gpt-5.4", undefined)).toBe("gpt-5.4"); - expect( - resolveCursorAcpModelId("claude-opus-4-6", { - reasoning: "high", - thinking: true, - contextWindow: "1m", - }), - ).toBe("claude-opus-4-6[effort=high,thinking=true,context=1m]"); - expect(resolveCursorAcpModelId("gpt-5.3-codex", undefined)).toBe( - "gpt-5.3-codex[reasoning=medium,fast=false]", - ); - }); - - it("preserves unrecognized ACP model slugs instead of forcing bracket notation", () => { - expect(resolveCursorAcpModelId("gpt-5.4-1m", undefined)).toBe("gpt-5.4-1m"); - expect(resolveCursorAcpModelId("auto", undefined)).toBe("auto"); - expect(resolveCursorAcpModelId("claude-4.6-opus", undefined)).toBe("claude-4.6-opus"); - }); - - it("passes custom models through unchanged", () => { - expect(resolveCursorAcpModelId("custom/internal-model", undefined)).toBe( - "custom/internal-model", - ); - }); -}); +const sessionNewCursorConfigOptions = [ + { + type: "select", + currentValue: "agent", + options: [ + { name: "Agent", value: "agent", description: "Full agent capabilities with tool access" }, + ], + category: "mode", + id: "mode", + name: "Mode", + description: "Controls how the agent executes tasks", + }, + { + type: "select", + currentValue: "composer-2", + options: [ + { name: "Auto", value: "default" }, + { name: "Composer 2", value: "composer-2" }, + { name: "GPT-5.4", value: "gpt-5.4" }, + { name: "Sonnet 4.6", value: "claude-sonnet-4-6" }, + { name: "Opus 4.6", value: "claude-opus-4-6" }, + { name: "Codex 5.3 Spark", value: "gpt-5.3-codex-spark" }, + ], + category: "model", + id: "model", + name: "Model", + description: "Controls which model is used for responses", + }, + { + type: "select", + currentValue: "true", + options: [ + { name: "Off", value: "false" }, + { name: "Fast", value: "true" }, + ], + category: "model_config", + id: "fast", + name: "Fast", + description: "Faster speeds.", + }, +] satisfies ReadonlyArray; -describe("getCursorModelCapabilities", () => { - it("resolves capabilities from canonical cursor base slugs", () => { - expect(getCursorModelCapabilities("gpt-5.4").contextWindowOptions).toEqual([ - { value: "272k", label: "272k", isDefault: true }, - { value: "1m", label: "1M" }, - ]); - expect(getCursorModelCapabilities("claude-opus-4-6").supportsThinkingToggle).toBe(true); +describe("getCursorFallbackModels", () => { + it("does not publish any built-in cursor models before ACP discovery", () => { + expect( + getCursorFallbackModels({ + customModels: ["internal/cursor-model"], + }).map((model) => model.slug), + ).toEqual(["internal/cursor-model"]); }); }); @@ -164,6 +176,85 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { }); }); +describe("buildCursorDiscoveredModelsFromConfigOptions", () => { + it("publishes ACP model choices immediately from session/new config options", () => { + expect(buildCursorDiscoveredModelsFromConfigOptions(sessionNewCursorConfigOptions)).toEqual([ + { + slug: "default", + name: "Auto", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex-spark", + name: "Codex 5.3 Spark", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]); + }); +}); + describe("parseCursorAboutOutput", () => { it("parses json about output and forwards subscription metadata", () => { expect( @@ -273,8 +364,12 @@ describe("Cursor parameterized model picker preview gating", () => { }); describe("resolveCursorAcpBaseModelId", () => { - it("drops parameterized ACP traits and preserves base model ids", () => { + it("drops bracket traits without rewriting raw ACP model ids", () => { expect(resolveCursorAcpBaseModelId("gpt-5.4[reasoning=medium,context=272k]")).toBe("gpt-5.4"); + expect(resolveCursorAcpBaseModelId("gpt-5.4-medium-fast")).toBe("gpt-5.4-medium-fast"); + expect(resolveCursorAcpBaseModelId("claude-4.6-opus-high-thinking")).toBe( + "claude-4.6-opus-high-thinking", + ); expect(resolveCursorAcpBaseModelId("composer-2")).toBe("composer-2"); expect(resolveCursorAcpBaseModelId("auto")).toBe("auto"); }); @@ -311,30 +406,3 @@ describe("resolveCursorAcpConfigUpdates", () => { ).toEqual([{ configId: "fast", value: "false" }]); }); }); - -describe("resolveCursorAgentModel", () => { - it("maps canonical base slugs onto agent CLI model ids", () => { - expect(resolveCursorAgentModel("composer-2", { fastMode: true })).toBe("composer-2-fast"); - expect(resolveCursorAgentModel("gpt-5.3-codex", { reasoning: "xhigh" })).toBe( - "gpt-5.3-codex-xhigh", - ); - expect( - resolveCursorAgentModel("gpt-5.4", { - reasoning: "medium", - fastMode: true, - contextWindow: "272k", - }), - ).toBe("gpt-5.4-medium-fast"); - expect(resolveCursorAgentModel("claude-opus-4-6", { thinking: true })).toBe( - "claude-4.6-opus-high-thinking", - ); - expect(resolveCursorAgentModel("auto", undefined)).toBe("auto"); - }); - - it("passes custom agent model ids through unchanged", () => { - expect(resolveCursorAgentModel("gpt-5.4-mini-medium", undefined)).toBe("gpt-5.4-mini-medium"); - expect(resolveCursorAgentModel("custom/internal-model", undefined)).toBe( - "custom/internal-model", - ); - }); -}); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index a7cfaff7a00..aff47135bcf 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -13,7 +13,6 @@ import type { ServerSettingsError, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -37,157 +36,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = { contextWindowOptions: [], promptInjectedEffortLevels: [], }; -const BUILT_IN_MODELS: ReadonlyArray = [ - { - slug: "default", - name: "Auto", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "composer-2", - name: "Composer 2", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "composer-1.5", - name: "Composer 1.5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex", - name: "Codex 5.3", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex-spark", - name: "Codex 5.3 Spark", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "272k", label: "272k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Sonnet 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gemini-3.1-pro", - name: "Gemini 3.1 Pro", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "grok-4-20", - name: "Grok 4.20", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, -]; const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; @@ -197,6 +45,41 @@ export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { }, } satisfies NonNullable; +function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): ServerProvider { + const checkedAt = new Date().toISOString(); + const models = getCursorFallbackModels(cursorSettings); + + if (!cursorSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cursor is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Cursor Agent availability...", + }, + }); +} + interface CursorSessionSelectOption { readonly value: string; readonly name: string; @@ -227,12 +110,6 @@ function flattenSessionConfigSelectOptions( ); } -function normalizeCursorAcpModelSlug(modelId: string): string { - const trimmed = modelId.trim(); - const base = trimmed.includes("[") ? trimmed.slice(0, trimmed.indexOf("[")) : trimmed; - return normalizeModelSlug(base, PROVIDER) ?? base; -} - function normalizeCursorThoughtLevelValue(value: string | null | undefined): string | undefined { const normalized = value?.trim().toLowerCase(); switch (normalized) { @@ -369,6 +246,75 @@ function buildCursorDiscoveredModels( }); } +export function buildCursorDiscoveredModelsFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + if (!configOptions || configOptions.length === 0) { + return []; + } + + const modelOption = findCursorModelConfigOption(configOptions); + const modelChoices = flattenSessionConfigSelectOptions(modelOption); + if (!modelOption || modelChoices.length === 0) { + return []; + } + + const currentModelValue = + modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; + const currentModelCapabilities = buildCursorCapabilitiesFromConfigOptions(configOptions); + + return buildCursorDiscoveredModels( + modelChoices.map((modelChoice) => ({ + slug: modelChoice.value.trim(), + name: modelChoice.name.trim(), + capabilities: + currentModelValue === modelChoice.value.trim() + ? currentModelCapabilities + : EMPTY_CAPABILITIES, + })), + ); +} + +const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + spawn: { + command: cursorSettings.binaryPath, + args: [ + ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), + "acp", + ], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + authMethodId: "cursor_login", + clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, + }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), + ); + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + }).pipe(Effect.scoped); + +function updateCursorModelCapabilities( + snapshot: ServerProvider, + modelSlug: string, + capabilities: ModelCapabilities, +): ServerProvider { + const nextModels = snapshot.models.map((model) => + model.slug === modelSlug ? { ...model, capabilities } : model, + ); + + return Equal.equals(snapshot.models, nextModels) + ? snapshot + : { + ...snapshot, + checkedAt: new Date().toISOString(), + models: nextModels, + }; +} + function normalizeCursorConfigOptionToken(value: string | null | undefined): string { return ( value @@ -402,8 +348,9 @@ function findCursorBooleanConfigValue( } export function resolveCursorAcpBaseModelId(model: string | null | undefined): string { - const normalized = normalizeModelSlug(model, PROVIDER) ?? "default"; - return normalized.includes("[") ? normalized.slice(0, normalized.indexOf("[")) : normalized; + const trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : "default"; + return base.includes("[") ? base.slice(0, base.indexOf("[")) : base; } export function resolveCursorAcpConfigUpdates( @@ -471,169 +418,67 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const acpContext = yield* Layer.build( - AcpSessionRuntime.layer({ - spawn: { - command: cursorSettings.binaryPath, - args: [ - ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), - "acp", - ], - cwd: process.cwd(), - }, - cwd: process.cwd(), - clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, - authMethodId: "cursor_login", - clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, - }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), + const acp = yield* makeCursorAcpProbeRuntime(cursorSettings); + const started = yield* acp.start(); + return buildCursorDiscoveredModelsFromConfigOptions( + started.sessionSetupResult.configOptions ?? [], ); - const acp = yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + }).pipe(Effect.scoped); + +const enrichCursorModelsViaAcp = (input: { + readonly cursorSettings: CursorSettings; + readonly snapshot: ServerProvider; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; +}) => + Effect.gen(function* () { + if (input.snapshot.models.length === 0) { + return; + } + + const acp = yield* makeCursorAcpProbeRuntime(input.cursorSettings); const started = yield* acp.start(); const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; const modelOption = findCursorModelConfigOption(initialConfigOptions); const modelChoices = flattenSessionConfigSelectOptions(modelOption); if (!modelOption || modelChoices.length === 0) { - return [] as const; + return; } - const fallbackBySlug = new Map(BUILT_IN_MODELS.map((model) => [model.slug, model] as const)); const currentModelValue = modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; + let currentSnapshot = input.snapshot; - const discoveredModels = yield* Effect.forEach( - modelChoices, - (modelChoice) => - Effect.gen(function* () { - const slug = normalizeCursorAcpModelSlug(modelChoice.value); - let configOptions: ReadonlyArray = - initialConfigOptions; - if (currentModelValue !== modelChoice.value) { - configOptions = yield* acp.setConfigOption(modelOption.id, modelChoice.value).pipe( - Effect.map((response) => response.configOptions ?? []), - Effect.catch(() => - Effect.succeed>([]), - ), - ); - } - const fallbackCapabilities = fallbackBySlug.get(slug)?.capabilities ?? EMPTY_CAPABILITIES; - return { - slug, - name: modelChoice.name, - capabilities: - configOptions.length > 0 - ? buildCursorCapabilitiesFromConfigOptions(configOptions) - : fallbackCapabilities, - } satisfies CursorAcpDiscoveredModel; - }), - { concurrency: 1 }, - ); - - return buildCursorDiscoveredModels(discoveredModels); - }).pipe(Effect.scoped); - -export function getCursorModelCapabilities(model: string | null | undefined): ModelCapabilities { - const slug = normalizeModelSlug(model, "cursor"); - return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES - ); -} - -/** - * Resolve the ACP model ID for a Cursor model to be sent to session/set_config_option - */ -export function resolveCursorAcpModelId( - model: string | null | undefined, - modelOptions: CursorModelOptions | null | undefined, -): string { - const slug = normalizeModelSlug(model, "cursor") ?? "auto"; - if (slug.includes("[") && slug.endsWith("]")) { - return slug; - } - const caps = getCursorModelCapabilities(slug); - const isBuiltIn = BUILT_IN_MODELS.some((candidate) => candidate.slug === slug); - if (!isBuiltIn) { - return slug; - } - - const traits: string[] = []; - - if (slug === "gpt-5.3-codex") { - const reasoning = resolveEffort(caps, modelOptions?.reasoning) ?? "medium"; - traits.push(`reasoning=${reasoning}`); - traits.push(`fast=${modelOptions?.fastMode === true}`); - return `${slug}[${traits.join(",")}]`; - } - - if (caps.supportsFastMode && modelOptions?.fastMode === true) { - traits.push("fast=true"); - } - - if (modelOptions?.reasoning !== undefined) { - const reasoning = resolveEffort(caps, modelOptions.reasoning); - if (reasoning) { - traits.push(`${slug.startsWith("claude-") ? "effort" : "reasoning"}=${reasoning}`); - } - } + for (const modelChoice of modelChoices) { + const modelSlug = modelChoice.value.trim(); + if (!modelSlug || modelSlug === currentModelValue) { + continue; + } - if (caps.supportsThinkingToggle && modelOptions?.thinking !== undefined) { - traits.push(`thinking=${modelOptions.thinking}`); - } + const nextConfigOptions = yield* acp.setConfigOption(modelOption.id, modelSlug).pipe( + Effect.map((response) => response.configOptions ?? []), + Effect.timeout("3 seconds"), + Effect.catch(() => Effect.succeed>([])), + ); + if (nextConfigOptions.length === 0) { + continue; + } - if (modelOptions?.contextWindow !== undefined) { - const contextWindow = resolveContextWindow(caps, modelOptions.contextWindow); - if (contextWindow) { - traits.push(`context=${contextWindow}`); + const nextSnapshot = updateCursorModelCapabilities( + currentSnapshot, + modelSlug, + buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), + ); + if (!Equal.equals(currentSnapshot, nextSnapshot)) { + currentSnapshot = nextSnapshot; + yield* input.publishSnapshot(nextSnapshot); + } } - } - - return traits.length > 0 ? `${slug}[${traits.join(",")}]` : slug; -} + }).pipe(Effect.scoped); -/** - * Resolve the Agent CLI model ID for a Cursor model to be set as `--model` arg for the `agent` command. - * - * Yes... Cursor uses different IDs. No... I don't know why. - */ -export function resolveCursorAgentModel( - model: string | null | undefined, - modelOptions: CursorModelOptions | null | undefined, -): string { - const normalized = normalizeModelSlug(model, "cursor") ?? "default"; - const slug = normalized.includes("[") ? normalized.slice(0, normalized.indexOf("[")) : normalized; - const caps = getCursorModelCapabilities(slug); - const reasoning = resolveEffort(caps, modelOptions?.reasoning); - const thinking = caps.supportsThinkingToggle ? (modelOptions?.thinking ?? true) : undefined; - const fastMode = modelOptions?.fastMode === true; - - switch (slug) { - case "default": - return "auto"; - case "composer-2": - return fastMode ? "composer-2-fast" : "composer-2"; - case "composer-1.5": - return "composer-1.5"; - case "gpt-5.3-codex": { - const suffix = reasoning && reasoning !== "medium" ? `-${reasoning}` : ""; - return `gpt-5.3-codex${suffix}${fastMode ? "-fast" : ""}`; - } - case "gpt-5.3-codex-spark": { - const suffix = reasoning && reasoning !== "medium" ? `-${reasoning}` : ""; - return `gpt-5.3-codex-spark-preview${suffix}`; - } - case "gpt-5.4": - return `gpt-5.4-${reasoning ?? "medium"}${fastMode ? "-fast" : ""}`; - case "claude-opus-4-6": - return thinking ? "claude-4.6-opus-high-thinking" : "claude-4.6-opus-high"; - case "claude-sonnet-4-6": - return thinking ? "claude-4.6-sonnet-medium-thinking" : "claude-4.6-sonnet-medium"; - case "gemini-3.1-pro": - return "gemini-3.1-pro"; - case "grok-4-20": - return thinking ? "grok-4-20-thinking" : "grok-4-20"; - default: - return slug === "default" ? "auto" : slug; - } +export function getCursorFallbackModels( + cursorSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings([], PROVIDER, cursorSettings.customModels, EMPTY_CAPABILITIES); } /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ @@ -990,12 +835,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( Effect.map((settings) => settings.providers.cursor), ); const checkedAt = new Date().toISOString(); - const fallbackModels = providerModelsFromSettings( - BUILT_IN_MODELS, - PROVIDER, - cursorSettings.customModels, - EMPTY_CAPABILITIES, - ); + const fallbackModels = getCursorFallbackModels(cursorSettings); if (!cursorSettings.enabled) { return buildServerProvider({ @@ -1088,7 +928,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( const models = providerModelsFromSettings( Option.getOrElse( Option.filter(discoveredModels, (models) => models.length > 0), - () => BUILT_IN_MODELS, + () => [] as const, ), PROVIDER, cursorSettings.customModels, @@ -1130,7 +970,23 @@ export const CursorProviderLive = Layer.effect( Stream.map((settings) => settings.providers.cursor), ), haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + buildInitialSnapshot: buildInitialCursorProviderSnapshot, checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + (settings.enabled && + snapshot.installed && + snapshot.auth.status !== "unauthenticated" && + snapshot.models.length > 0 + ? enrichCursorModelsViaAcp({ + cursorSettings: settings, + snapshot, + publishSnapshot, + }) + : Effect.void + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.catchCause((cause) => Effect.logError(cause)), + ), }); }), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b81d70b9cfe..d43869e837b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -609,8 +609,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const registry = yield* ProviderRegistry; const initial = yield* registry.getProviders; + assert.deepStrictEqual(Array.isArray(initial), true); + + for (let attempt = 0; attempt < 100; attempt += 1) { + const refreshed = yield* registry.getProviders; + if (refreshed.find((status) => status.provider === "codex")?.status === "ready") { + break; + } + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 5))); + } + + const ready = yield* registry.getProviders; assert.strictEqual( - initial.find((status) => status.provider === "codex")?.status, + ready.find((status) => status.provider === "codex")?.status, "ready", ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index bd007169cc7..1c974602778 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -9,22 +9,28 @@ import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; import { ClaudeProviderLive } from "./ClaudeProvider"; import { CodexProviderLive } from "./CodexProvider"; import { CursorProviderLive } from "./CursorProvider"; -import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; -import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; -import type { CursorProviderShape } from "../Services/CursorProvider"; import { CursorProvider } from "../Services/CursorProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; -const loadProviders = ( - codexProvider: CodexProviderShape, - claudeProvider: ClaudeProviderShape, - cursorProvider: CursorProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot, cursorProvider.getSnapshot], { - concurrency: "unbounded", - }); +const PROVIDER_ORDER: ReadonlyArray = ["codex", "claudeAgent", "cursor"]; + +const sortProviders = (providers: ReadonlyArray): ReadonlyArray => + [...providers].toSorted( + (left, right) => + PROVIDER_ORDER.indexOf(left.provider as ProviderKind) - + PROVIDER_ORDER.indexOf(right.provider as ProviderKind), + ); + +const upsertProvider = ( + providers: ReadonlyArray, + nextProvider: ServerProvider, +): ReadonlyArray => + sortProviders([ + ...providers.filter((provider) => provider.provider !== nextProvider.provider), + nextProvider, + ]); export const haveProvidersChanged = ( previousProviders: ReadonlyArray, @@ -41,15 +47,16 @@ export const ProviderRegistryLive = Layer.effect( PubSub.unbounded>(), PubSub.shutdown, ); - const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider, cursorProvider), - ); + const providersRef = yield* Ref.make>([]); - const syncProviders = Effect.fn("syncProviders")(function* (options?: { - readonly publish?: boolean; - }) { + const applyProviderSnapshot = Effect.fn("applyProviderSnapshot")(function* ( + nextProvider: ServerProvider, + options?: { + readonly publish?: boolean; + }, + ) { const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider, cursorProvider); + const providers = upsertProvider(previousProviders, nextProvider); yield* Ref.set(providersRef, providers); if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { @@ -59,44 +66,69 @@ export const ProviderRegistryLive = Layer.effect( return providers; }); - yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, - ); - yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, - ); - yield* Stream.runForEach(cursorProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, - ); + const loadInitialProvider = (effect: Effect.Effect) => + effect.pipe( + Effect.flatMap((provider) => applyProviderSnapshot(provider)), + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + Effect.asVoid, + ); + + const refreshProviders = Effect.fn("refreshProviders")(function* (options?: { + readonly publish?: boolean; + }) { + const snapshots = yield* Effect.all( + [codexProvider.refresh, claudeProvider.refresh, cursorProvider.refresh], + { + concurrency: "unbounded", + }, + ); + for (const snapshot of snapshots) { + yield* applyProviderSnapshot(snapshot, options); + } + return yield* Ref.get(providersRef); + }); + + yield* Stream.runForEach(codexProvider.streamChanges, (provider) => + Effect.asVoid(applyProviderSnapshot(provider)), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(claudeProvider.streamChanges, (provider) => + Effect.asVoid(applyProviderSnapshot(provider)), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(cursorProvider.streamChanges, (provider) => + Effect.asVoid(applyProviderSnapshot(provider)), + ).pipe(Effect.forkScoped); + + yield* loadInitialProvider(codexProvider.getSnapshot); + yield* loadInitialProvider(claudeProvider.getSnapshot); + yield* loadInitialProvider(cursorProvider.getSnapshot); const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { switch (provider) { - case "codex": - yield* codexProvider.refresh; + case "codex": { + const snapshot = yield* codexProvider.refresh; + yield* applyProviderSnapshot(snapshot); break; - case "claudeAgent": - yield* claudeProvider.refresh; + } + case "claudeAgent": { + const snapshot = yield* claudeProvider.refresh; + yield* applyProviderSnapshot(snapshot); break; - case "cursor": - yield* cursorProvider.refresh; + } + case "cursor": { + const snapshot = yield* cursorProvider.refresh; + yield* applyProviderSnapshot(snapshot); break; + } default: - yield* Effect.all( - [codexProvider.refresh, claudeProvider.refresh, cursorProvider.refresh], - { - concurrency: "unbounded", - }, - ); + yield* refreshProviders({ publish: true }); break; } - return yield* syncProviders(); + return yield* Ref.get(providersRef); }); return { - getProviders: syncProviders({ publish: false }).pipe( - Effect.tapError(Effect.logError), - Effect.orElseSucceed(() => []), - ), + getProviders: Ref.get(providersRef), refresh: (provider?: ProviderKind) => refresh(provider).pipe( Effect.tapError(Effect.logError), diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts index 2866130c29f..94de569b2b2 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.test.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -10,8 +10,8 @@ const parameterizedGpt54ConfigOptions: ReadonlyArray { await Effect.runPromise( applyCursorAcpModelSelection({ runtime, - model: "gpt-5.4[reasoning=medium,context=272k]", + model: "gpt-5.4-medium-fast[reasoning=medium,context=272k]", modelOptions: { reasoning: "xhigh", contextWindow: "1m", @@ -114,7 +114,7 @@ describe("applyCursorAcpModelSelection", () => { ); expect(calls).toEqual([ - { type: "model", value: "gpt-5.4" }, + { type: "model", value: "gpt-5.4-medium-fast" }, { type: "config", configId: "reasoning", value: "extra-high" }, { type: "config", configId: "context", value: "1m" }, { type: "config", configId: "fast", value: "true" }, diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts new file mode 100644 index 00000000000..27e79cd067b --- /dev/null +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -0,0 +1,142 @@ +import { describe, it, assert } from "@effect/vitest"; +import type { ServerProvider } from "@t3tools/contracts"; +import { Deferred, Effect, Fiber, Ref, Stream } from "effect"; + +import { makeManagedServerProvider } from "./makeManagedServerProvider"; + +interface TestSettings { + readonly enabled: boolean; +} + +const initialSnapshot: ServerProvider = { + provider: "codex", + enabled: true, + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-04-10T00:00:00.000Z", + message: "Checking provider availability...", + models: [], + slashCommands: [], + skills: [], +}; + +const refreshedSnapshot: ServerProvider = { + provider: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:01.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +const enrichedSnapshot: ServerProvider = { + ...refreshedSnapshot, + checkedAt: "2026-04-10T00:00:02.000Z", + models: [ + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], +}; + +describe("makeManagedServerProvider", () => { + it.effect( + "returns the initial snapshot while the first provider check runs in the background", + () => + Effect.scoped( + Effect.gen(function* () { + const releaseCheck = yield* Deferred.make(); + const checkStarted = yield* Deferred.make(); + const checkCalls = yield* Ref.make(0); + + const provider = yield* makeManagedServerProvider({ + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + buildInitialSnapshot: () => initialSnapshot, + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.flatMap(() => Deferred.succeed(checkStarted, undefined)), + Effect.flatMap(() => Deferred.await(releaseCheck)), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 hour", + }); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const firstSnapshot = yield* provider.getSnapshot; + const secondSnapshot = yield* provider.getSnapshot; + yield* Deferred.await(checkStarted); + + assert.deepStrictEqual(firstSnapshot, initialSnapshot); + assert.deepStrictEqual(secondSnapshot, initialSnapshot); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + + yield* Deferred.succeed(releaseCheck, undefined); + + const updates = Array.from(yield* Fiber.join(updatesFiber)); + const latestSnapshot = yield* provider.getSnapshot; + + assert.deepStrictEqual(updates, [refreshedSnapshot]); + assert.deepStrictEqual(latestSnapshot, refreshedSnapshot); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ), + ); + + it.effect("streams supplemental snapshot updates after the base provider check completes", () => + Effect.scoped( + Effect.gen(function* () { + const releaseCheck = yield* Deferred.make(); + const releaseEnrichment = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + buildInitialSnapshot: () => initialSnapshot, + checkProvider: Deferred.await(releaseCheck).pipe(Effect.as(refreshedSnapshot)), + enrichSnapshot: ({ publishSnapshot }) => + Deferred.await(releaseEnrichment).pipe( + Effect.flatMap(() => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: "1 hour", + }); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const initial = yield* provider.getSnapshot; + assert.deepStrictEqual(initial, initialSnapshot); + + yield* Deferred.succeed(releaseCheck, undefined); + yield* Deferred.succeed(releaseEnrichment, undefined); + + const updates = Array.from(yield* Fiber.join(updatesFiber)); + const latest = yield* provider.getSnapshot; + + assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]); + assert.deepStrictEqual(latest, enrichedSnapshot); + }), + ), + ); +}); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 59aeac1ab5f..5e1cf1609a6 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -1,5 +1,5 @@ import type { ServerProvider } from "@t3tools/contracts"; -import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; +import { Duration, Effect, Equal, Fiber, PubSub, Ref, Scope, Stream } from "effect"; import * as Semaphore from "effect/Semaphore"; import type { ServerProviderShape } from "./Services/ServerProvider"; @@ -11,18 +11,74 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly getSettings: Effect.Effect; readonly streamSettings: Stream.Stream; readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; + readonly buildInitialSnapshot: (settings: Settings) => ServerProvider; readonly checkProvider: Effect.Effect; + readonly enrichSnapshot?: (input: { + readonly settings: Settings; + readonly snapshot: ServerProvider; + readonly getSnapshot: Effect.Effect; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + }) => Effect.Effect; readonly refreshInterval?: Duration.Input; }): Effect.fn.Return { + type InitialRefreshState = "idle" | "running" | "done"; const refreshSemaphore = yield* Semaphore.make(1); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, ); const initialSettings = yield* input.getSettings; - const initialSnapshot = yield* input.checkProvider; + const initialSnapshot = input.buildInitialSnapshot(initialSettings); const snapshotRef = yield* Ref.make(initialSnapshot); const settingsRef = yield* Ref.make(initialSettings); + const initialRefreshStateRef = yield* Ref.make("idle"); + const enrichmentFiberRef = yield* Ref.make | null>(null); + const enrichmentGenerationRef = yield* Ref.make(0); + const scope = yield* Effect.scope; + + const publishEnrichedSnapshot = Effect.fn("publishEnrichedSnapshot")(function* ( + generation: number, + nextSnapshot: ServerProvider, + ) { + const currentGeneration = yield* Ref.get(enrichmentGenerationRef); + if (currentGeneration !== generation) { + return; + } + + const previousSnapshot = yield* Ref.get(snapshotRef); + if (Equal.equals(previousSnapshot, nextSnapshot)) { + return; + } + + yield* Ref.set(snapshotRef, nextSnapshot); + yield* PubSub.publish(changesPubSub, nextSnapshot); + }); + + const restartSnapshotEnrichment = Effect.fn("restartSnapshotEnrichment")(function* ( + settings: Settings, + snapshot: ServerProvider, + generation: number, + ) { + const previousFiber = yield* Ref.getAndSet(enrichmentFiberRef, null); + if (previousFiber) { + yield* Fiber.interrupt(previousFiber).pipe(Effect.ignore); + } + + if (!input.enrichSnapshot) { + return; + } + + const fiber = yield* input + .enrichSnapshot({ + settings, + snapshot, + getSnapshot: Ref.get(snapshotRef), + publishSnapshot: (nextSnapshot) => publishEnrichedSnapshot(generation, nextSnapshot), + }) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(scope)); + + yield* Ref.set(enrichmentFiberRef, fiber); + }); const applySnapshotBase = Effect.fn("applySnapshot")(function* ( nextSettings: Settings, @@ -38,7 +94,10 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const nextSnapshot = yield* input.checkProvider; yield* Ref.set(settingsRef, nextSettings); yield* Ref.set(snapshotRef, nextSnapshot); + yield* Ref.set(initialRefreshStateRef, "done"); yield* PubSub.publish(changesPubSub, nextSnapshot); + const generation = yield* Ref.updateAndGet(enrichmentGenerationRef, (value) => value + 1); + yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, generation); return nextSnapshot; }); const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => @@ -49,6 +108,28 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); + const startInitialRefreshIfNeeded = Effect.fn("startInitialRefreshIfNeeded")(function* () { + const shouldStart = yield* Ref.modify( + initialRefreshStateRef, + (state): readonly [boolean, InitialRefreshState] => + state === "idle" ? [true, "running"] : [false, state], + ); + + if (!shouldStart) { + return; + } + + yield* refreshSnapshot().pipe( + Effect.onExit((exit) => + exit._tag === "Failure" + ? Ref.update(initialRefreshStateRef, (state) => (state === "running" ? "idle" : state)) + : Effect.void, + ), + Effect.ignoreCause({ log: true }), + Effect.forkIn(scope), + ); + }); + yield* Stream.runForEach(input.streamSettings, (nextSettings) => Effect.asVoid(applySnapshot(nextSettings)), ).pipe(Effect.forkScoped); @@ -61,10 +142,14 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ).pipe(Effect.forkScoped); return { - getSnapshot: input.getSettings.pipe( - Effect.flatMap(applySnapshot), - Effect.tapError(Effect.logError), - Effect.orDie, + getSnapshot: startInitialRefreshIfNeeded().pipe( + Effect.flatMap(() => + input.getSettings.pipe( + Effect.flatMap(applySnapshot), + Effect.tapError(Effect.logError), + Effect.orDie, + ), + ), ), refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), get streamChanges() { diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 686a36b60dd..76bc6f947ae 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -4,6 +4,7 @@ import { type ModelSelection, ClaudeModelOptions, CodexModelOptions, + CursorModelOptions, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, EnvironmentId, @@ -434,6 +435,90 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt }; } +async function mountCursorPicker(props: { model?: string; options?: CursorModelOptions }) { + const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.cursor; + const cursorThreadId = ThreadId.make("thread-cursor-traits"); + const cursorThreadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, cursorThreadId); + const cursorThreadKey = scopedThreadKey(cursorThreadRef); + const host = document.createElement("div"); + document.body.append(host); + + useComposerDraftStore.setState({ + draftsByThreadKey: { + [cursorThreadKey]: { + prompt: "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: { + cursor: { + provider: "cursor", + model, + ...(props.options ? { options: props.options } : {}), + }, + }, + activeProvider: "cursor", + runtimeMode: null, + interactionMode: null, + }, + }, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); + + const screen = await render( + {}} + />, + { container: host }, + ); + + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + + return { + [Symbol.asyncDispose]: cleanup, + cleanup, + }; +} + describe("TraitsPicker (Codex)", () => { afterEach(() => { document.body.innerHTML = ""; @@ -501,3 +586,70 @@ describe("TraitsPicker (Codex)", () => { }); }); }); + +describe("TraitsPicker (Cursor)", () => { + afterEach(() => { + document.body.innerHTML = ""; + localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + stickyModelSelectionByProvider: {}, + }); + }); + + it("uses the selected fast mode menu label for the trigger in fast-only state", async () => { + await using _ = await mountCursorPicker({ + model: "composer-2", + options: { fastMode: false }, + }); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Normal"); + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Fast Mode"); + expect(text).toContain("off"); + expect(text).toContain("on"); + }); + }); + + it("shows Normal for Cursor Opus 4.6 when fast mode and context window are both at defaults", async () => { + await using _ = await mountCursorPicker({ + model: "claude-opus-4-6", + options: { fastMode: false }, + }); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Normal"); + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Fast Mode"); + expect(text).toContain("Context Window"); + expect(text).toContain("200K (default)"); + expect(text).toContain("1M"); + }); + }); + + it("shows Normal · 1M for Cursor Opus 4.6 when fast mode is off and context window is overridden", async () => { + await using _ = await mountCursorPicker({ + model: "claude-opus-4-6", + options: { fastMode: false, contextWindow: "1m" }, + }); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Normal · 1M"); + }); + }); +}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index bee0b67e7d3..46598b13abc 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -433,6 +433,13 @@ export const TraitsPicker = memo(function TraitsPicker({ const effortLabel = effort ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) : null; + const primaryTraitLabel = ultrathinkPromptControlled + ? "Ultrathink" + : effortLabel + ? effortLabel + : thinkingEnabled === null + ? null + : `Thinking ${thinkingEnabled ? "On" : "Off"}`; const contextWindowLabel = showContextWindow && contextWindow !== defaultContextWindow ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null) @@ -452,23 +459,26 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } + const selectedTriggerTraits = [ + primaryTraitLabel, + ...(caps.supportsFastMode && + (fastModeEnabled || (primaryTraitLabel === null && contextWindowLabel !== null)) + ? [fastModeEnabled ? "Fast" : "Normal"] + : []), + ...(contextWindowLabel ? [contextWindowLabel] : []), + ].filter(Boolean); const triggerLabel = fastOnlyControl ? fastModeEnabled ? "Fast" : "Normal" - : [ - ultrathinkPromptControlled - ? "Ultrathink" - : effortLabel - ? effortLabel - : thinkingEnabled === null - ? null - : `Thinking ${thinkingEnabled ? "On" : "Off"}`, - ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), - ...(contextWindowLabel ? [contextWindowLabel] : []), - ] - .filter(Boolean) - .join(" · "); + : selectedTriggerTraits.length > 0 + ? selectedTriggerTraits.join(" · ") + : caps.supportsFastMode + ? "Normal" + : defaultContextWindow + ? (contextWindowOptions.find((option) => option.value === defaultContextWindow)?.label ?? + defaultContextWindow) + : ""; const isCodexStyle = provider === "codex"; From dc7e19c9d33aecb5b74ecad755dc302cce91eb49 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 13 Apr 2026 11:04:01 -0700 Subject: [PATCH 51/82] Preserve cursor model when traits change - keep the selected provider model when updating sticky traits - add regression coverage for Cursor draft model selection --- apps/web/src/components/chat/TraitsPicker.tsx | 3 ++- apps/web/src/composerDraftStore.test.ts | 27 +++++++++++++++++++ apps/web/src/composerDraftStore.ts | 8 ++++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 46598b13abc..7c8af8993c9 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -223,10 +223,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ return; } setProviderModelOptions(threadTarget, provider, nextOptions, { + model, persistSticky: true, }); }, - [persistence, provider, setProviderModelOptions], + [model, persistence, provider, setProviderModelOptions], ); const { caps, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 16f348d3127..d41fec072c3 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -986,6 +986,33 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("preserves the selected Cursor model when only traits change", () => { + const store = useComposerDraftStore.getState(); + + store.setProviderModelOptions( + threadRef, + "cursor", + { + reasoning: "high", + }, + { + model: "gpt-5.4", + persistSticky: true, + }, + ); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual( + modelSelection("cursor", "gpt-5.4", { + reasoning: "high", + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toEqual( + modelSelection("cursor", "gpt-5.4", { + reasoning: "high", + }), + ); + }); + it("updates only the draft when sticky persistence is omitted", () => { const store = useComposerDraftStore.getState(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f70de2f0d16..3c1e4b0c4ca 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -345,6 +345,7 @@ interface ComposerDraftStoreState { provider: ProviderKind, nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, options?: { + model?: string | null | undefined; persistSticky?: boolean; }, ) => void; @@ -2336,6 +2337,9 @@ const composerDraftStore = create()( if (normalizedProvider === null) { return; } + const fallbackModel = + normalizeModelSlug(options?.model, normalizedProvider) ?? + DEFAULT_MODEL_BY_PROVIDER[normalizedProvider]; // Normalize just this provider's options const normalizedOpts = normalizeProviderModelOptions( { [normalizedProvider]: nextProviderOptions }, @@ -2353,7 +2357,7 @@ const composerDraftStore = create()( if (providerOpts) { nextMap[normalizedProvider] = { provider: normalizedProvider, - model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], + model: currentForProvider?.model ?? fallbackModel, options: providerOpts, }; } else if (currentForProvider?.options) { @@ -2371,7 +2375,7 @@ const composerDraftStore = create()( base.modelSelectionByProvider[normalizedProvider] ?? ({ provider: normalizedProvider, - model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], + model: fallbackModel, } as ModelSelection); if (providerOpts) { nextStickyMap[normalizedProvider] = { From c689f218614ce97e3bfeee980d9397fea3a80366 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 13 Apr 2026 13:35:48 -0700 Subject: [PATCH 52/82] Add Cursor model cache and max-effort support - Cache discovered Cursor models with freshness checks - Prefer Cursor model_option effort controls and preserve max - Update Cursor ACP probes, contracts, and examples --- .../provider/Layers/CursorProvider.test.ts | 196 ++++++++ .../src/provider/Layers/CursorProvider.ts | 447 +++++++++++------- .../provider/acp/CursorAcpCliProbe.test.ts | 10 +- .../chat/composerProviderRegistry.test.tsx | 19 + packages/contracts/src/model.ts | 2 +- .../examples/cursor-acp-client.example.ts | 17 +- 6 files changed, 502 insertions(+), 189 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 1e0d42c2f29..52bedc5c838 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest"; import type * as EffectAcpSchema from "effect-acp/schema"; import { + buildCursorDiscoveredModelsCacheKey, buildCursorCapabilitiesFromConfigOptions, buildCursorDiscoveredModelsFromConfigOptions, + getFreshCursorDiscoveredModelsFromCache, getCursorFallbackModels, getCursorParameterizedModelPickerUnsupportedMessage, + mergeCursorDiscoveredModelsWithCachedCapabilities, parseCursorAboutOutput, parseCursorCliConfigChannel, parseCursorVersionDate, @@ -90,6 +93,64 @@ const parameterizedClaudeConfigOptions = [ }, ] satisfies ReadonlyArray; +const parameterizedClaudeModelOptionConfigOptions = [ + { + type: "select", + currentValue: "claude-opus-4-6", + options: [{ name: "Opus 4.6", value: "claude-opus-4-6" }], + category: "model", + id: "model", + name: "Model", + }, + { + type: "select", + currentValue: "high", + options: [ + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + ], + category: "thought_level", + id: "reasoning", + name: "Reasoning", + }, + { + type: "select", + currentValue: "max", + options: [ + { name: "Low", value: "low" }, + { name: "Medium", value: "medium" }, + { name: "High", value: "high" }, + { name: "Max", value: "max" }, + ], + category: "model_option", + id: "effort", + name: "Effort", + }, + { + type: "select", + currentValue: "true", + options: [ + { name: "Off", value: "false" }, + { name: "Fast", value: "true" }, + ], + category: "model_config", + id: "fast", + name: "Fast", + }, + { + type: "select", + currentValue: "true", + options: [ + { name: "Off", value: "false" }, + { name: ":icon-brain:", value: "true" }, + ], + category: "model_config", + id: "thinking", + name: "Thinking", + }, +] satisfies ReadonlyArray; + const sessionNewCursorConfigOptions = [ { type: "select", @@ -142,6 +203,112 @@ describe("getCursorFallbackModels", () => { }); }); +describe("Cursor discovered model cache helpers", () => { + it("reuses discovered Cursor models while the cache entry is fresh", () => { + const models = buildCursorDiscoveredModelsFromConfigOptions(sessionNewCursorConfigOptions); + const cacheKey = buildCursorDiscoveredModelsCacheKey({ + binaryPath: "/usr/local/bin/agent", + apiEndpoint: "http://localhost:3000", + version: "2026.04.13-abcd123", + }); + + expect( + getFreshCursorDiscoveredModelsFromCache({ + cache: { + cacheKey, + expiresAtMs: 20_000, + models, + }, + cacheKey, + nowMs: 10_000, + }), + ).toEqual(models); + }); + + it("ignores expired or mismatched Cursor model cache entries", () => { + const models = buildCursorDiscoveredModelsFromConfigOptions(sessionNewCursorConfigOptions); + + expect( + getFreshCursorDiscoveredModelsFromCache({ + cache: { + cacheKey: "a", + expiresAtMs: 5_000, + models, + }, + cacheKey: "a", + nowMs: 5_000, + }), + ).toBeUndefined(); + expect( + getFreshCursorDiscoveredModelsFromCache({ + cache: { + cacheKey: "a", + expiresAtMs: 10_000, + models, + }, + cacheKey: "b", + nowMs: 1_000, + }), + ).toBeUndefined(); + }); + + it("fills missing discovered Cursor capabilities from the last cached snapshot", () => { + expect( + mergeCursorDiscoveredModelsWithCachedCapabilities( + [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + ], + supportsFastMode: true, + supportsThinkingToggle: true, + contextWindowOptions: [{ value: "200k", label: "200K", isDefault: true }], + promptInjectedEffortLevels: [], + }, + }, + ], + ), + ).toEqual([ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + ], + supportsFastMode: true, + supportsThinkingToggle: true, + contextWindowOptions: [{ value: "200k", label: "200K", isDefault: true }], + promptInjectedEffortLevels: [], + }, + }, + ]); + }); +}); + describe("buildCursorCapabilitiesFromConfigOptions", () => { it("derives model capabilities from parameterized Cursor ACP config options", () => { expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual({ @@ -174,6 +341,23 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { promptInjectedEffortLevels: [], }); }); + + it("prefers the newer model_option effort control over legacy thought_level", () => { + expect( + buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeModelOptionConfigOptions), + ).toEqual({ + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "max", label: "Max", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }); + }); }); describe("buildCursorDiscoveredModelsFromConfigOptions", () => { @@ -405,4 +589,16 @@ describe("resolveCursorAcpConfigUpdates", () => { }), ).toEqual([{ configId: "fast", value: "false" }]); }); + + it("writes Cursor effort changes through the newer model_option config when available", () => { + expect( + resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, { + reasoning: "max", + thinking: false, + }), + ).toEqual([ + { configId: "effort", value: "max" }, + { configId: "thinking", value: "false" }, + ]); + }); }); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index aff47135bcf..9abebf61fea 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -13,7 +13,7 @@ import type { ServerSettingsError, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; +import { Effect, Equal, Layer, Option, Ref, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { @@ -38,6 +38,8 @@ const EMPTY_CAPABILITIES: ModelCapabilities = { }; const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const CURSOR_MODEL_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const CURSOR_REFRESH_INTERVAL = "1 hour"; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { _meta: { @@ -91,6 +93,12 @@ interface CursorAcpDiscoveredModel { readonly capabilities: ModelCapabilities; } +interface CursorDiscoveredModelsCacheEntry { + readonly cacheKey: string; + readonly expiresAtMs: number; + readonly models: ReadonlyArray; +} + function flattenSessionConfigSelectOptions( configOption: EffectAcpSchema.SessionConfigOption | undefined, ): ReadonlyArray { @@ -110,12 +118,13 @@ function flattenSessionConfigSelectOptions( ); } -function normalizeCursorThoughtLevelValue(value: string | null | undefined): string | undefined { +function normalizeCursorReasoningValue(value: string | null | undefined): string | undefined { const normalized = value?.trim().toLowerCase(); switch (normalized) { case "low": case "medium": case "high": + case "max": return normalized; case "xhigh": case "extra-high": @@ -132,6 +141,37 @@ function findCursorModelConfigOption( return configOptions.find((option) => option.category === "model"); } +function getCursorConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string { + return option.category?.trim().toLowerCase() ?? ""; +} + +function isCursorEffortConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return ( + id === "effort" || + id === "reasoning" || + name === "effort" || + name === "reasoning" || + name.includes("effort") || + name.includes("reasoning") + ); +} + +function findCursorEffortConfigOption( + configOptions: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + const candidates = configOptions.filter( + (option) => option.type === "select" && isCursorEffortConfigOption(option), + ); + return ( + candidates.find((option) => getCursorConfigOptionCategory(option) === "model_option") ?? + candidates.find((option) => option.id.trim().toLowerCase() === "effort") ?? + candidates.find((option) => getCursorConfigOptionCategory(option) === "thought_level") ?? + candidates[0] + ); +} + function isCursorContextConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { const id = option.id.trim().toLowerCase(); const name = option.name.trim().toLowerCase(); @@ -170,11 +210,11 @@ export function buildCursorCapabilitiesFromConfigOptions( return EMPTY_CAPABILITIES; } - const reasoningConfig = configOptions.find((option) => option.category === "thought_level"); + const reasoningConfig = findCursorEffortConfigOption(configOptions); const reasoningEffortLevels = reasoningConfig?.type === "select" ? flattenSessionConfigSelectOptions(reasoningConfig).flatMap((entry) => { - const normalizedValue = normalizeCursorThoughtLevelValue(entry.value); + const normalizedValue = normalizeCursorReasoningValue(entry.value); if (!normalizedValue) { return []; } @@ -182,7 +222,7 @@ export function buildCursorCapabilitiesFromConfigOptions( { value: normalizedValue, label: entry.name, - ...(normalizeCursorThoughtLevelValue(reasoningConfig.currentValue) === normalizedValue + ...(normalizeCursorReasoningValue(reasoningConfig.currentValue) === normalizedValue ? { isDefault: true } : {}), }, @@ -297,24 +337,6 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); }).pipe(Effect.scoped); -function updateCursorModelCapabilities( - snapshot: ServerProvider, - modelSlug: string, - capabilities: ModelCapabilities, -): ServerProvider { - const nextModels = snapshot.models.map((model) => - model.slug === modelSlug ? { ...model, capabilities } : model, - ); - - return Equal.equals(snapshot.models, nextModels) - ? snapshot - : { - ...snapshot, - checkedAt: new Date().toISOString(), - models: nextModels, - }; -} - function normalizeCursorConfigOptionToken(value: string | null | undefined): string { return ( value @@ -363,12 +385,12 @@ export function resolveCursorAcpConfigUpdates( const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = []; - const reasoningOption = configOptions.find((option) => option.category === "thought_level"); - const requestedReasoning = normalizeCursorThoughtLevelValue(modelOptions?.reasoning); + const reasoningOption = findCursorEffortConfigOption(configOptions); + const requestedReasoning = normalizeCursorReasoningValue(modelOptions?.reasoning); if (reasoningOption && requestedReasoning) { const value = findCursorSelectOptionValue(reasoningOption, (option) => { - const normalizedValue = normalizeCursorThoughtLevelValue(option.value); - const normalizedName = normalizeCursorThoughtLevelValue(option.name); + const normalizedValue = normalizeCursorReasoningValue(option.value); + const normalizedName = normalizeCursorReasoningValue(option.name); return normalizedValue === requestedReasoning || normalizedName === requestedReasoning; }); if (value) { @@ -420,37 +442,26 @@ const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => Effect.gen(function* () { const acp = yield* makeCursorAcpProbeRuntime(cursorSettings); const started = yield* acp.start(); - return buildCursorDiscoveredModelsFromConfigOptions( - started.sessionSetupResult.configOptions ?? [], - ); - }).pipe(Effect.scoped); - -const enrichCursorModelsViaAcp = (input: { - readonly cursorSettings: CursorSettings; - readonly snapshot: ServerProvider; - readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; -}) => - Effect.gen(function* () { - if (input.snapshot.models.length === 0) { - return; - } - - const acp = yield* makeCursorAcpProbeRuntime(input.cursorSettings); - const started = yield* acp.start(); const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; const modelOption = findCursorModelConfigOption(initialConfigOptions); const modelChoices = flattenSessionConfigSelectOptions(modelOption); if (!modelOption || modelChoices.length === 0) { - return; + return []; } const currentModelValue = modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; - let currentSnapshot = input.snapshot; + const cachedCapabilitiesBySlug = new Map(); + if (currentModelValue) { + cachedCapabilitiesBySlug.set( + currentModelValue, + buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), + ); + } for (const modelChoice of modelChoices) { const modelSlug = modelChoice.value.trim(); - if (!modelSlug || modelSlug === currentModelValue) { + if (!modelSlug || cachedCapabilitiesBySlug.has(modelSlug)) { continue; } @@ -463,16 +474,19 @@ const enrichCursorModelsViaAcp = (input: { continue; } - const nextSnapshot = updateCursorModelCapabilities( - currentSnapshot, + cachedCapabilitiesBySlug.set( modelSlug, buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), ); - if (!Equal.equals(currentSnapshot, nextSnapshot)) { - currentSnapshot = nextSnapshot; - yield* input.publishSnapshot(nextSnapshot); - } } + + return buildCursorDiscoveredModels( + modelChoices.map((modelChoice) => ({ + slug: modelChoice.value.trim(), + name: modelChoice.name.trim(), + capabilities: cachedCapabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, + })), + ); }).pipe(Effect.scoped); export function getCursorFallbackModels( @@ -481,6 +495,64 @@ export function getCursorFallbackModels( return providerModelsFromSettings([], PROVIDER, cursorSettings.customModels, EMPTY_CAPABILITIES); } +export function buildCursorDiscoveredModelsCacheKey(input: { + readonly binaryPath: string; + readonly apiEndpoint?: string | null; + readonly version?: string | null; +}): string { + return JSON.stringify({ + binaryPath: input.binaryPath, + apiEndpoint: input.apiEndpoint ?? null, + version: input.version ?? null, + }); +} + +export function getFreshCursorDiscoveredModelsFromCache(input: { + readonly cache: CursorDiscoveredModelsCacheEntry | null | undefined; + readonly cacheKey: string; + readonly nowMs: number; +}): ReadonlyArray | undefined { + if (!input.cache) { + return undefined; + } + if (input.cache.cacheKey !== input.cacheKey) { + return undefined; + } + if (input.cache.expiresAtMs <= input.nowMs) { + return undefined; + } + return input.cache.models; +} + +export function mergeCursorDiscoveredModelsWithCachedCapabilities( + discoveredModels: ReadonlyArray, + cachedModels: ReadonlyArray | null | undefined, +): ReadonlyArray { + if (!cachedModels || cachedModels.length === 0) { + return discoveredModels; + } + + const cachedBySlug = new Map(cachedModels.map((model) => [model.slug, model])); + return discoveredModels.map((model) => { + const cached = cachedBySlug.get(model.slug); + if (!cached) { + return model; + } + const capabilities = model.capabilities ?? EMPTY_CAPABILITIES; + return { + ...model, + capabilities: + capabilities.reasoningEffortLevels.length > 0 || + capabilities.supportsFastMode || + capabilities.supportsThinkingToggle || + capabilities.contextWindowOptions.length > 0 || + capabilities.promptInjectedEffortLevels.length > 0 + ? capabilities + : cached.capabilities, + }; + }); +} + /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ const ABOUT_TIMEOUT_MS = 8_000; @@ -824,139 +896,168 @@ const runCursorAboutCommand = Effect.gen(function* () { return yield* runCursorCommand(["about"]); }); -export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( - function* (): Effect.fn.Return< - ServerProvider, - ServerSettingsError, - ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService - > { - const cursorSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.cursor), - ); - const checkedAt = new Date().toISOString(); - const fallbackModels = getCursorFallbackModels(cursorSettings); - - if (!cursorSettings.enabled) { - return buildServerProvider({ - provider: PROVIDER, - enabled: false, - checkedAt, - models: fallbackModels, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Cursor is disabled in T3 Code settings.", - }, - }); - } +export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( + discoveredModelsCacheRef: Ref.Ref, +): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService +> { + const cursorSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.cursor), + ); + const checkedAt = new Date().toISOString(); + const fallbackModels = getCursorFallbackModels(cursorSettings); - // Single `agent about` probe: returns version + auth status in one call. - const aboutProbe = yield* runCursorAboutCommand.pipe( - Effect.timeoutOption(ABOUT_TIMEOUT_MS), - Effect.result, - ); + if (!cursorSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cursor is disabled in T3 Code settings.", + }, + }); + } - if (Result.isFailure(aboutProbe)) { - const error = aboutProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." - : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, - }, - }); - } + // Single `agent about` probe: returns version + auth status in one call. + const aboutProbe = yield* runCursorAboutCommand.pipe( + Effect.timeoutOption(ABOUT_TIMEOUT_MS), + Effect.result, + ); - if (Option.isNone(aboutProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: "Cursor Agent CLI is installed but timed out while running `agent about`.", - }, - }); - } + if (Result.isFailure(aboutProbe)) { + const error = aboutProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." + : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } - const parsed = parseCursorAboutOutput(aboutProbe.success.value); - const parameterizedModelPickerUnsupportedMessage = - getCursorParameterizedModelPickerUnsupportedMessage({ - version: parsed.version, - channel: readCursorCliConfigChannel(), - }); - if (parameterizedModelPickerUnsupportedMessage) { - return buildServerProvider({ - provider: PROVIDER, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: parsed.version, - status: "error", - auth: parsed.auth, - message: - parsed.auth.status === "unauthenticated" && parsed.message - ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}` - : parameterizedModelPickerUnsupportedMessage, - }, - }); - } - let discoveredModels = Option.none>(); - if (parsed.auth.status !== "unauthenticated") { - discoveredModels = yield* discoverCursorModelsViaAcp(cursorSettings).pipe( - Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - Effect.catch(() => Effect.succeed(Option.none>())), - ); - } - const models = providerModelsFromSettings( - Option.getOrElse( - Option.filter(discoveredModels, (models) => models.length > 0), - () => [] as const, - ), - PROVIDER, - cursorSettings.customModels, - EMPTY_CAPABILITIES, - ); + if (Option.isNone(aboutProbe.success)) { return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, checkedAt, - models, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Cursor Agent CLI is installed but timed out while running `agent about`.", + }, + }); + } + + const parsed = parseCursorAboutOutput(aboutProbe.success.value); + const nowMs = Date.now(); + const discoveryCacheKey = buildCursorDiscoveredModelsCacheKey({ + binaryPath: cursorSettings.binaryPath, + apiEndpoint: cursorSettings.apiEndpoint, + version: parsed.version, + }); + const parameterizedModelPickerUnsupportedMessage = + getCursorParameterizedModelPickerUnsupportedMessage({ + version: parsed.version, + channel: readCursorCliConfigChannel(), + }); + if (parameterizedModelPickerUnsupportedMessage) { + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, probe: { installed: true, version: parsed.version, - status: parsed.status, + status: "error", auth: parsed.auth, - ...(parsed.message ? { message: parsed.message } : {}), + message: + parsed.auth.status === "unauthenticated" && parsed.message + ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}` + : parameterizedModelPickerUnsupportedMessage, }, }); - }, -); + } + let discoveredModels = Option.none>(); + if (parsed.auth.status !== "unauthenticated") { + const cachedModels = getFreshCursorDiscoveredModelsFromCache({ + cache: yield* Ref.get(discoveredModelsCacheRef), + cacheKey: discoveryCacheKey, + nowMs, + }); + if (cachedModels && cachedModels.length > 0) { + discoveredModels = Option.some(cachedModels); + } else { + const staleCachedModels = (yield* Ref.get(discoveredModelsCacheRef))?.models; + discoveredModels = yield* discoverCursorModelsViaAcp(cursorSettings).pipe( + Effect.map((models) => + mergeCursorDiscoveredModelsWithCachedCapabilities(models, staleCachedModels), + ), + Effect.tap((models) => + models.length > 0 + ? Ref.set(discoveredModelsCacheRef, { + cacheKey: discoveryCacheKey, + expiresAtMs: nowMs + CURSOR_MODEL_CACHE_TTL_MS, + models, + }) + : Effect.void, + ), + Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.catch(() => Effect.succeed(Option.none>())), + ); + } + } + const models = providerModelsFromSettings( + Option.getOrElse( + Option.filter(discoveredModels, (models) => models.length > 0), + () => [] as const, + ), + PROVIDER, + cursorSettings.customModels, + EMPTY_CAPABILITIES, + ); + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsed.version, + status: parsed.status, + auth: parsed.auth, + ...(parsed.message ? { message: parsed.message } : {}), + }, + }); +}); export const CursorProviderLive = Layer.effect( CursorProvider, Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const discoveredModelsCacheRef = yield* Ref.make(null); - const checkProvider = checkCursorProviderStatus().pipe( + const checkProvider = checkCursorProviderStatus(discoveredModelsCacheRef).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); @@ -972,21 +1073,7 @@ export const CursorProviderLive = Layer.effect( haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), buildInitialSnapshot: buildInitialCursorProviderSnapshot, checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => - (settings.enabled && - snapshot.installed && - snapshot.auth.status !== "unauthenticated" && - snapshot.models.length > 0 - ? enrichCursorModelsViaAcp({ - cursorSettings: settings, - snapshot, - publishSnapshot, - }) - : Effect.void - ).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.catchCause((cause) => Effect.logError(cause)), - ), + refreshInterval: CURSOR_REFRESH_INTERVAL, }); }), ); diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index e36b9f34b6a..f8d22533514 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -55,7 +55,10 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", if (Array.isArray(configOptions)) { const modelConfig = configOptions.find((opt) => opt.category === "model"); const parameterizedOptions = configOptions.filter( - (opt) => opt.category === "thought_level" || opt.category === "model_config", + (opt) => + opt.category === "thought_level" || + opt.category === "model_option" || + opt.category === "model_config", ); console.log("Model config option:", JSON.stringify(modelConfig, null, 2)); console.log( @@ -112,7 +115,10 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", if (Array.isArray(setResult.configOptions)) { const modelConfig = setResult.configOptions.find((opt) => opt.category === "model"); const parameterizedOptions = setResult.configOptions.filter( - (opt) => opt.category === "thought_level" || opt.category === "model_config", + (opt) => + opt.category === "thought_level" || + opt.category === "model_option" || + opt.category === "model_config", ); if (modelConfig?.type === "select") { expect(modelConfig.currentValue).toBe("gpt-5.4"); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 208eabd647f..3975f0b5fd7 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -97,6 +97,7 @@ const CURSOR_MODELS: ReadonlyArray = [ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, ], supportsFastMode: false, supportsThinkingToggle: true, @@ -503,6 +504,24 @@ describe("getComposerProviderState", () => { expect(state.modelOptionsForDispatch).toHaveProperty("thinking", true); }); + it("preserves Cursor max effort for Claude-family models", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "claude-opus-4-6", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { reasoning: "max" }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: "max", + modelOptionsForDispatch: { reasoning: "max" }, + }); + }); + it("preserves Claude default effort explicitly in dispatch options", () => { const state = getComposerProviderState({ provider: "claudeAgent", diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index e96d6746f03..51172e5e914 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -6,7 +6,7 @@ export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max", "ultrathink"] as const; export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; -export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "xhigh"] as const; +export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "max", "xhigh"] as const; export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number]; export type ProviderReasoningEffort = diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index b31ed66352b..765423045c6 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -30,34 +30,39 @@ const program = Effect.gen(function* () { }, }), ); - yield* acp.handleSessionUpdate((notification) => - Console.log("session/update", JSON.stringify(notification)), - ); + // yield* acp.handleSessionUpdate((notification) => + // Console.log("session/update", JSON.stringify(notification)), + // ); const initialized = yield* acp.agent.initialize({ protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, + _meta: { + parameterizedModelPicker: true, + }, }, clientInfo: { name: "effect-acp-example", version: "0.0.0", }, }); - yield* Console.log("initialized", JSON.stringify(initialized)); + yield* Console.log("initialized", JSON.stringify(initialized, null, 4)); const session = yield* acp.agent.createSession({ cwd: process.cwd(), mcpServers: [], }); - yield* acp.agent.setSessionConfigOption({ + const config = yield* acp.agent.setSessionConfigOption({ sessionId: session.sessionId, configId: "model", - value: "gpt-5.4[reasoning=medium,context=272k,fast=false]", + value: "claude-opus-4-6", }); + yield* Console.log("config", JSON.stringify(config, null, 4)); + const result = yield* acp.agent.prompt({ sessionId: session.sessionId, prompt: [ From 218cd8b2c8c85433f679592312aec336f7a92102 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 13 Apr 2026 18:42:40 -0700 Subject: [PATCH 53/82] Speed up Cursor provider discovery Stop probing every Cursor model via ACP during provider refresh, and preserve the last good discovered model snapshot when a later refresh returns sparse model data. Co-authored-by: codex --- .../src/provider/Layers/CursorProvider.ts | 46 +-------- .../provider/Layers/ProviderRegistry.test.ts | 93 ++++++++++++++++++- .../src/provider/Layers/ProviderRegistry.ts | 46 ++++++++- 3 files changed, 139 insertions(+), 46 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 9da04cf3e8d..eae4720a54a 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -435,50 +435,8 @@ const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => Effect.gen(function* () { const acp = yield* makeCursorAcpProbeRuntime(cursorSettings); const started = yield* acp.start(); - const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; - const modelOption = findCursorModelConfigOption(initialConfigOptions); - const modelChoices = flattenSessionConfigSelectOptions(modelOption); - if (!modelOption || modelChoices.length === 0) { - return []; - } - - const currentModelValue = - modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; - const cachedCapabilitiesBySlug = new Map(); - if (currentModelValue) { - cachedCapabilitiesBySlug.set( - currentModelValue, - buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), - ); - } - - for (const modelChoice of modelChoices) { - const modelSlug = modelChoice.value.trim(); - if (!modelSlug || cachedCapabilitiesBySlug.has(modelSlug)) { - continue; - } - - const nextConfigOptions = yield* acp.setConfigOption(modelOption.id, modelSlug).pipe( - Effect.map((response) => response.configOptions ?? []), - Effect.timeout("3 seconds"), - Effect.catch(() => Effect.succeed>([])), - ); - if (nextConfigOptions.length === 0) { - continue; - } - - cachedCapabilitiesBySlug.set( - modelSlug, - buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), - ); - } - - return buildCursorDiscoveredModels( - modelChoices.map((modelChoice) => ({ - slug: modelChoice.value.trim(), - name: modelChoice.name.trim(), - capabilities: cachedCapabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, - })), + return buildCursorDiscoveredModelsFromConfigOptions( + started.sessionSetupResult.configOptions ?? [], ); }).pipe(Effect.scoped); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 84f32f93d6b..8113d50e884 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -30,7 +30,11 @@ import { readCodexConfigModelProvider, } from "./CodexProvider"; import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; -import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; +import { + haveProvidersChanged, + mergeProviderSnapshot, + ProviderRegistryLive, +} from "./ProviderRegistry"; import { ServerConfig } from "../../config"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; import { ProviderRegistry } from "../Services/ProviderRegistry"; @@ -563,6 +567,93 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); }); + it("preserves previously discovered provider models when a refresh returns none", () => { + const previousProvider = { + provider: "cursor", + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], + supportsFastMode: true, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + + it("fills missing capabilities from the previous provider snapshot", () => { + const previousProvider = { + provider: "cursor", + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2026.04.09-f2b0fcd", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], + supportsFastMode: true, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [ + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + it.effect("does not probe provider health during registry startup", () => Effect.gen(function* () { let spawnCount = 0; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 231a7257cb3..009680149ae 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -35,6 +35,47 @@ const loadProviders = ( concurrency: "unbounded", }); +const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => + (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 || + model.capabilities?.supportsFastMode === true || + model.capabilities?.supportsThinkingToggle === true || + (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || + (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0; + +const mergeProviderModels = ( + previousModels: ReadonlyArray, + nextModels: ReadonlyArray, +): ReadonlyArray => { + if (nextModels.length === 0 && previousModels.length > 0) { + return previousModels; + } + + const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); + const mergedModels = nextModels.map((model) => { + const previousModel = previousBySlug.get(model.slug); + if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { + return model; + } + return { + ...model, + capabilities: previousModel.capabilities, + }; + }); + const nextSlugs = new Set(nextModels.map((model) => model.slug)); + return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; +}; + +export const mergeProviderSnapshot = ( + previousProvider: ServerProvider | undefined, + nextProvider: ServerProvider, +): ServerProvider => + !previousProvider + ? nextProvider + : { + ...nextProvider, + models: mergeProviderModels(previousProvider.models, nextProvider.models), + }; + export const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, @@ -121,7 +162,10 @@ export const ProviderRegistryLive = Layer.effect( ); for (const provider of nextProviders) { - mergedProviders.set(provider.provider, provider); + mergedProviders.set( + provider.provider, + mergeProviderSnapshot(mergedProviders.get(provider.provider), provider), + ); } const providers = orderProviderSnapshots([...mergedProviders.values()]); From e9bf9dc3e16381ff2f9b7a4ff33b83fe9f1b89cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 13 Apr 2026 18:51:15 -0700 Subject: [PATCH 54/82] Keep Cursor capability discovery off the refresh path Refresh the Cursor provider quickly from ACP session config, then enrich per-model capabilities in the background with bounded parallelism. Preserve the last good provider model snapshot so sparse refreshes do not wipe discovered capabilities. Co-authored-by: codex --- .../src/provider/Layers/CursorProvider.ts | 128 ++++++++++++++++++ .../makeManagedServerProvider.test.ts | 60 +++++++- .../src/provider/makeManagedServerProvider.ts | 56 +++++++- 3 files changed, 242 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index eae4720a54a..6362115bdbd 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -38,6 +38,8 @@ const EMPTY_CAPABILITIES: ModelCapabilities = { }; const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; +const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4; const CURSOR_REFRESH_INTERVAL = "1 hour"; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { @@ -279,6 +281,16 @@ function buildCursorDiscoveredModels( }); } +function hasCursorModelCapabilities(model: Pick): boolean { + return ( + (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 || + model.capabilities?.supportsFastMode === true || + model.capabilities?.supportsThinkingToggle === true || + (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || + (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0 + ); +} + export function buildCursorDiscoveredModelsFromConfigOptions( configOptions: ReadonlyArray | null | undefined, ): ReadonlyArray { @@ -440,6 +452,93 @@ const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => ); }).pipe(Effect.scoped); +const discoverCursorModelCapabilitiesViaAcp = ( + cursorSettings: CursorSettings, + existingModels: ReadonlyArray, +) => + Effect.gen(function* () { + const acp = yield* makeCursorAcpProbeRuntime(cursorSettings); + const started = yield* acp.start(); + const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; + const modelOption = findCursorModelConfigOption(initialConfigOptions); + const modelChoices = flattenSessionConfigSelectOptions(modelOption); + if (!modelOption || modelChoices.length === 0) { + return []; + } + + const currentModelValue = + modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; + const capabilitiesBySlug = new Map(); + if (currentModelValue) { + capabilitiesBySlug.set( + currentModelValue, + buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), + ); + } + + const targetModelSlugs = new Set( + existingModels + .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model)) + .map((model) => model.slug), + ); + if (targetModelSlugs.size === 0) { + return buildCursorDiscoveredModels( + modelChoices.map((modelChoice) => ({ + slug: modelChoice.value.trim(), + name: modelChoice.name.trim(), + capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, + })), + ); + } + + const probedCapabilities = yield* Effect.forEach( + modelChoices, + (modelChoice) => { + const modelSlug = modelChoice.value.trim(); + if (!modelSlug || !targetModelSlugs.has(modelSlug) || capabilitiesBySlug.has(modelSlug)) { + return Effect.void; + } + + return Effect.gen(function* () { + const probeAcp = yield* makeCursorAcpProbeRuntime(cursorSettings); + const probeStarted = yield* probeAcp.start(); + const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? []; + const probeModelOption = findCursorModelConfigOption(probeConfigOptions); + const probeCurrentModelValue = + probeModelOption?.type === "select" + ? probeModelOption.currentValue?.trim() || undefined + : undefined; + const nextConfigOptions = + probeCurrentModelValue === modelSlug + ? probeConfigOptions + : yield* probeAcp + .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug) + .pipe(Effect.map((response) => response.configOptions ?? probeConfigOptions)); + return [modelSlug, buildCursorCapabilitiesFromConfigOptions(nextConfigOptions)] as const; + }).pipe( + Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), + Effect.catch(() => Effect.void), + ); + }, + { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, + ); + + for (const entry of probedCapabilities) { + if (!entry) { + continue; + } + capabilitiesBySlug.set(entry[0], entry[1]); + } + + return buildCursorDiscoveredModels( + modelChoices.map((modelChoice) => ({ + slug: modelChoice.value.trim(), + name: modelChoice.name.trim(), + capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, + })), + ); + }).pipe(Effect.scoped); + export function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { @@ -937,6 +1036,35 @@ export const CursorProviderLive = Layer.effect( haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), initialSnapshot: buildInitialCursorProviderSnapshot, checkProvider, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => { + if ( + !settings.enabled || + snapshot.auth.status === "unauthenticated" || + !snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model)) + ) { + return Effect.void; + } + + return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.flatMap((discoveredModels) => { + if (discoveredModels.length === 0) { + return Effect.void; + } + + return publishSnapshot({ + ...snapshot, + models: providerModelsFromSettings( + discoveredModels, + PROVIDER, + settings.customModels, + EMPTY_CAPABILITIES, + ), + }); + }), + Effect.catch(() => Effect.void), + ); + }, refreshInterval: CURSOR_REFRESH_INTERVAL, }); }), diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index f500e165f01..1b06b2f3464 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -1,6 +1,6 @@ import { describe, it, assert } from "@effect/vitest"; import type { ServerProvider } from "@t3tools/contracts"; -import { Effect, Fiber, PubSub, Ref, Stream } from "effect"; +import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect"; import { makeManagedServerProvider } from "./makeManagedServerProvider"; @@ -35,6 +35,25 @@ const refreshedSnapshot: ServerProvider = { skills: [], }; +const enrichedSnapshot: ServerProvider = { + ...refreshedSnapshot, + checkedAt: "2026-04-10T00:00:02.000Z", + models: [ + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], +}; + describe("makeManagedServerProvider", () => { it.effect("keeps the initial snapshot until an explicit refresh runs", () => Effect.scoped( @@ -61,6 +80,7 @@ describe("makeManagedServerProvider", () => { Stream.runCollect, Effect.forkChild, ); + yield* Effect.yieldNow; const refreshed = yield* provider.refresh; const updates = Array.from(yield* Fiber.join(updatesFiber)); @@ -95,6 +115,7 @@ describe("makeManagedServerProvider", () => { Stream.runCollect, Effect.forkChild, ); + yield* Effect.yieldNow; yield* Ref.set(settingsRef, { enabled: false }); yield* PubSub.publish(settingsChanges, { enabled: false }); @@ -108,4 +129,41 @@ describe("makeManagedServerProvider", () => { }), ), ); + + it.effect("streams supplemental snapshot updates after the base provider check completes", () => + Effect.scoped( + Effect.gen(function* () { + const releaseEnrichment = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => initialSnapshot, + checkProvider: Effect.succeed(refreshedSnapshot), + enrichSnapshot: ({ publishSnapshot }) => + Deferred.await(releaseEnrichment).pipe( + Effect.flatMap(() => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: "1 hour", + }); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + const refreshed = yield* provider.refresh; + assert.deepStrictEqual(refreshed, refreshedSnapshot); + + yield* Deferred.succeed(releaseEnrichment, undefined); + + const updates = Array.from(yield* Fiber.join(updatesFiber)); + const latest = yield* provider.getSnapshot; + + assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]); + assert.deepStrictEqual(latest, enrichedSnapshot); + }), + ), + ); }); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 856594c1f02..09cf7f644d1 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -1,5 +1,5 @@ import type { ServerProvider } from "@t3tools/contracts"; -import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; +import { Duration, Effect, Equal, Fiber, PubSub, Ref, Scope, Stream } from "effect"; import * as Semaphore from "effect/Semaphore"; import type { ServerProviderShape } from "./Services/ServerProvider"; @@ -13,6 +13,12 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => ServerProvider; readonly checkProvider: Effect.Effect; + readonly enrichSnapshot?: (input: { + readonly settings: Settings; + readonly snapshot: ServerProvider; + readonly getSnapshot: Effect.Effect; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + }) => Effect.Effect; readonly refreshInterval?: Duration.Input; }): Effect.fn.Return { const refreshSemaphore = yield* Semaphore.make(1); @@ -24,6 +30,53 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const initialSnapshot = input.initialSnapshot(initialSettings); const snapshotRef = yield* Ref.make(initialSnapshot); const settingsRef = yield* Ref.make(initialSettings); + const enrichmentFiberRef = yield* Ref.make | null>(null); + const enrichmentGenerationRef = yield* Ref.make(0); + const scope = yield* Effect.scope; + + const publishEnrichedSnapshot = Effect.fn("publishEnrichedSnapshot")(function* ( + generation: number, + nextSnapshot: ServerProvider, + ) { + const currentGeneration = yield* Ref.get(enrichmentGenerationRef); + if (currentGeneration !== generation) { + return; + } + + const previousSnapshot = yield* Ref.get(snapshotRef); + if (Equal.equals(previousSnapshot, nextSnapshot)) { + return; + } + + yield* Ref.set(snapshotRef, nextSnapshot); + yield* PubSub.publish(changesPubSub, nextSnapshot); + }); + + const restartSnapshotEnrichment = Effect.fn("restartSnapshotEnrichment")(function* ( + settings: Settings, + snapshot: ServerProvider, + ) { + const previousFiber = yield* Ref.getAndSet(enrichmentFiberRef, null); + if (previousFiber) { + yield* Fiber.interrupt(previousFiber).pipe(Effect.ignore); + } + + if (!input.enrichSnapshot) { + return; + } + + const generation = yield* Ref.updateAndGet(enrichmentGenerationRef, (value) => value + 1); + const fiber = yield* input + .enrichSnapshot({ + settings, + snapshot, + getSnapshot: Ref.get(snapshotRef), + publishSnapshot: (nextSnapshot) => publishEnrichedSnapshot(generation, nextSnapshot), + }) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(scope)); + + yield* Ref.set(enrichmentFiberRef, fiber); + }); const applySnapshotBase = Effect.fn("applySnapshot")(function* ( nextSettings: Settings, @@ -40,6 +93,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( yield* Ref.set(settingsRef, nextSettings); yield* Ref.set(snapshotRef, nextSnapshot); yield* PubSub.publish(changesPubSub, nextSnapshot); + yield* restartSnapshotEnrichment(nextSettings, nextSnapshot); return nextSnapshot; }); const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => From 6c36109e5040c5544858fb134044e926dc69cbc4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 13 Apr 2026 19:03:12 -0700 Subject: [PATCH 55/82] Fix desktop backend readiness probe Co-authored-by: codex --- apps/desktop/src/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index cdabb2d74fb..e211c87744a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -375,6 +375,7 @@ async function waitForBackendHttpReady( try { await waitForHttpReady(baseUrl, { + path: options?.path ?? "/.well-known/t3/environment", ...options, signal: controller.signal, }); From c3f926c968613e3a00ac578922459dd3e043d9f7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 13 Apr 2026 23:51:53 -0700 Subject: [PATCH 56/82] Close cursor ACP runtime after generation - add ACP exit logging for the mock agent - ensure Cursor text generation closes its child runtime - cover process shutdown in the live test --- apps/server/scripts/acp-mock-agent.ts | 22 ++++++++ .../git/Layers/CursorTextGeneration.test.ts | 53 +++++++++++++++++++ .../src/git/Layers/CursorTextGeneration.ts | 1 + 3 files changed, 76 insertions(+) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index ec728eea653..f82ebb6d983 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -11,6 +11,7 @@ import * as AcpError from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; +const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; @@ -29,6 +30,27 @@ let currentContext = "272k"; let currentFast = false; const cancelledSessions = new Set(); +function logExit(reason: string): void { + if (!exitLogPath) { + return; + } + appendFileSync(exitLogPath, `${reason}\n`, "utf8"); +} + +process.once("SIGTERM", () => { + logExit("SIGTERM"); + process.exit(0); +}); + +process.once("SIGINT", () => { + logExit("SIGINT"); + process.exit(0); +}); + +process.once("exit", (code) => { + logExit(`exit:${code}`); +}); + function configOptions(): ReadonlyArray { if (parameterizedModelPicker) { const baseOptions: Array = [ diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts index 2c010a93ffb..2dc819bdfcb 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts @@ -96,6 +96,22 @@ function withFakeAcpAgent( }); } +function waitForFileContent(path: string): Effect.Effect { + return Effect.promise(async () => { + const deadline = Date.now() + 5_000; + for (;;) { + try { + return readFileSync(path, "utf8"); + } catch (error) { + if (Date.now() >= deadline) { + throw error instanceof Error ? error : new Error(String(error)); + } + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + }); +} + it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { it.effect("uses ACP model config options instead of raw CLI model ids", () => { const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); @@ -250,4 +266,41 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { }), ), ); + + it.effect("closes the ACP child process after text generation completes", () => { + const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); + const exitLogPath = path.join(exitLogDir, "exit.log"); + + return withFakeAcpAgent( + { + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Close runtime after generation", + body: "", + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-runtime-close", + stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", + modelSelection: { + provider: "cursor", + model: "composer-2", + }, + }); + + expect(generated.subject).toBe("Close runtime after generation"); + + const exitLog = yield* waitForFileContent(exitLogPath); + expect(exitLog).toContain("exit:0"); + + rmSync(exitLogDir, { recursive: true, force: true }); + }), + ); + }); }); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts index 754f3737eb5..6bf907dfca7 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -132,6 +132,7 @@ const makeCursorTextGeneration = Effect.gen(function* () { cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, }); + yield* Effect.addFinalizer(() => runtime.close); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; From e78ff4355992f8c17fc2c2ac9c7f7b9ce72ea055 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 14 Apr 2026 19:12:15 -0700 Subject: [PATCH 57/82] Harden ACP session handling and provider refreshes - ignore interrupt-only turn start failures and preserve session state - make ACP runtime/client handling more robust with distinct ids and buffered events - fix stale provider enrichment, empty model options, and Cursor model discovery --- apps/desktop/src/main.ts | 2 +- apps/server/scripts/acp-mock-agent.ts | 2 +- .../cursor-acp-model-mismatch-probe.ts | 8 +- .../Layers/ProviderCommandReactor.test.ts | 38 +++ .../Layers/ProviderCommandReactor.ts | 18 +- .../src/provider/Layers/CursorAdapter.test.ts | 86 +++++++ .../src/provider/Layers/CursorAdapter.ts | 20 +- .../provider/Layers/CursorProvider.test.ts | 48 ++++ .../src/provider/Layers/CursorProvider.ts | 6 +- .../src/provider/acp/AcpRuntimeModel.test.ts | 18 ++ .../src/provider/acp/AcpRuntimeModel.ts | 12 +- .../makeManagedServerProvider.test.ts | 80 ++++++ .../src/provider/makeManagedServerProvider.ts | 56 ++-- apps/web/src/composerDraftStore.test.ts | 18 ++ apps/web/src/composerDraftStore.ts | 15 +- packages/effect-acp/src/_internal/shared.ts | 2 +- packages/effect-acp/src/agent.test.ts | 80 +++++- packages/effect-acp/src/agent.ts | 2 +- packages/effect-acp/src/client.test.ts | 240 ++++++++++++++++++ packages/effect-acp/src/client.ts | 79 ++++-- packages/effect-acp/src/protocol.ts | 3 +- 21 files changed, 756 insertions(+), 77 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e211c87744a..eaa238d2d06 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -375,8 +375,8 @@ async function waitForBackendHttpReady( try { await waitForHttpReady(baseUrl, { - path: options?.path ?? "/.well-known/t3/environment", ...options, + path: options?.path ?? "/.well-known/t3/environment", signal: controller.signal, }); } finally { diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index f82ebb6d983..26ffa084a83 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -287,7 +287,7 @@ const program = Effect.gen(function* () { yield* agent.handleCancel(({ sessionId }) => Effect.sync(() => { - cancelledSessions.add(String(sessionId)); + cancelledSessions.add(String(sessionId ?? "mock-session-1")); }), ); diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts index 0f81b3b088b..f3152ab1786 100644 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -316,10 +316,10 @@ async function setSelectOptionIfAdvertised( sessionId, configId: option.id, value, - })) as SetConfigResult; + })) as SetConfigResult | null | undefined; logSection(`SET_${label}_RESPONSE`, response); - return response.configOptions ?? configOptions; + return response?.configOptions ?? configOptions; } async function main() { @@ -377,10 +377,10 @@ async function main() { sessionId, configId: modelConfig.id, value: targetModel, - })) as SetConfigResult; + })) as SetConfigResult | null | undefined; logSection("SET_MODEL_RESPONSE", setModelResponse); - configOptions = setModelResponse.configOptions ?? configOptions; + configOptions = setModelResponse?.configOptions ?? configOptions; configOptions = await setSelectOptionIfAdvertised( rpc, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index dc1881a53d2..939fc121cdc 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -410,6 +410,44 @@ describe("ProviderCommandReactor", () => { }); }); + it("does not record shutdown-style interrupts as session errors", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.sendTurn.mockImplementation(() => Effect.interrupt as never); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-interrupt-only"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-interrupt-only"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session).toMatchObject({ + status: "ready", + activeTurnId: null, + lastError: null, + }); + expect( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed"), + ).toBe(false); + }); + it("generates a thread title on the first turn", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 23516642ccb..aa9c2b8e5ce 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -604,6 +604,9 @@ const make = Effect.gen(function* () { } const handleTurnStartFailure = (cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } const detail = formatFailureDetail(cause); return setThreadSessionErrorOnTurnStartFailure({ threadId: event.payload.threadId, @@ -620,9 +623,22 @@ const make = Effect.gen(function* () { createdAt: event.payload.createdAt, }), ), + Effect.asVoid, ); }; + const recoverTurnStartFailure = (cause: Cause.Cause) => + handleTurnStartFailure(cause).pipe( + Effect.catchCause((recoveryCause) => + Effect.logWarning("provider command reactor failed to recover turn start failure", { + eventType: event.type, + threadId: event.payload.threadId, + cause: Cause.pretty(recoveryCause), + originalCause: Cause.pretty(cause), + }), + ), + ); + const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, messageText: message.text, @@ -643,7 +659,7 @@ const make = Effect.gen(function* () { yield* providerService .sendTurn(sendTurnRequest.value) - .pipe(Effect.catchCause(handleTurnStartFailure), Effect.forkScoped); + .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 1ac85b83bad..cf4bf52e689 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -797,6 +797,92 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect("interrupting a session settles pending user-input waits", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-interrupt-pending-user-input"); + const userInputRequested = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_ASK_QUESTION: "1" }), + ); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId) || event.type !== "user-input.requested") { + return Effect.void; + } + return Deferred.succeed(userInputRequested, undefined).pipe(Effect.ignore); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "ask me a question and then interrupt", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(userInputRequested); + yield* adapter.interruptTurn(threadId); + yield* Fiber.await(sendTurnFiber); + + assert.equal(yield* adapter.hasSession(threadId), true); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("broadcasts runtime events to multiple stream consumers", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-runtime-event-broadcast"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const firstConsumer = yield* Stream.take(adapter.streamEvents, 3).pipe( + Stream.runCollect, + Effect.forkChild, + ); + const secondConsumer = yield* Stream.take(adapter.streamEvents, 3).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }); + + const firstEvents = Array.from(yield* Fiber.join(firstConsumer)); + const secondEvents = Array.from(yield* Fiber.join(secondConsumer)); + + assert.deepStrictEqual( + firstEvents.map((event) => event.type), + ["session.started", "session.state.changed", "thread.started"], + ); + assert.deepStrictEqual( + secondEvents.map((event) => event.type), + ["session.started", "session.state.changed", "thread.started"], + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("switches model in-session via session/set_config_option", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 5613dacba2a..601d162c048 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -25,7 +25,7 @@ import { Fiber, FileSystem, Layer, - Queue, + PubSub, Random, Stream, } from "effect"; @@ -237,16 +237,18 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { stream: "native", }) : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const sessions = new Map(); - const runtimeEventQueue = yield* Queue.unbounded(); + const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); const offerRuntimeEvent = (event: ProviderRuntimeEvent) => - Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); const logNative = ( threadId: ThreadId, @@ -496,7 +498,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { yield* acp.handleRequestPermission((params) => Effect.gen(function* () { yield* logNative(input.threadId, "session/request_permission", params, "acp.jsonrpc"); - if (ctx?.session.runtimeMode === "full-access") { + if (input.runtimeMode === "full-access") { const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); if (autoApprovedOptionId !== undefined) { return { @@ -824,6 +826,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { Effect.gen(function* () { const ctx = yield* requireSession(threadId); yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); yield* Effect.ignore( ctx.acp.cancel.pipe( Effect.mapError((error) => @@ -910,10 +913,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { yield* Effect.addFinalizer(() => Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( - Effect.tap(() => Queue.shutdown(runtimeEventQueue)), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), ), ); + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + return { provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session" }, @@ -928,9 +934,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { listSessions, hasSession, stopAll, - get streamEvents() { - return Stream.fromQueue(runtimeEventQueue); - }, + streamEvents, } satisfies CursorAdapterShape; }); } diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index a0edbc6114b..23975657930 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1,9 +1,17 @@ +import * as path from "node:path"; +import * as os from "node:os"; +import { chmod, mkdtemp, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect } from "effect"; import { describe, expect, it } from "vitest"; import type * as EffectAcpSchema from "effect-acp/schema"; import { buildCursorCapabilitiesFromConfigOptions, buildCursorDiscoveredModelsFromConfigOptions, + discoverCursorModelsViaAcp, getCursorFallbackModels, getCursorParameterizedModelPickerUnsupportedMessage, parseCursorAboutOutput, @@ -13,6 +21,24 @@ import { resolveCursorAcpConfigUpdates, } from "./CursorProvider.ts"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +async function makeMockAgentWrapper(extraEnv?: Record) { + const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-")); + const wrapperPath = path.join(dir, "fake-agent.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@" +`; + await writeFile(wrapperPath, script, "utf8"); + await chmod(wrapperPath, 0o755); + return wrapperPath; +} + const parameterizedGpt54ConfigOptions = [ { type: "select", @@ -330,6 +356,28 @@ describe("buildCursorDiscoveredModelsFromConfigOptions", () => { }); }); +describe("discoverCursorModelsViaAcp", () => { + it("keeps the ACP probe runtime alive long enough to discover models", async () => { + const wrapperPath = await makeMockAgentWrapper(); + + const models = await Effect.runPromise( + discoverCursorModelsViaAcp({ + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + expect(models.map((model) => model.slug)).toEqual([ + "default", + "composer-2", + "gpt-5.4", + "claude-opus-4-6", + ]); + }); +}); + describe("parseCursorAboutOutput", () => { it("parses json about output and forwards subscription metadata", () => { expect( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 6362115bdbd..11875d0cf20 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -340,7 +340,7 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), ); return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); - }).pipe(Effect.scoped); + }); function normalizeCursorConfigOptionToken(value: string | null | undefined): string { return ( @@ -443,7 +443,7 @@ export function resolveCursorAcpConfigUpdates( return updates; } -const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => +export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => Effect.gen(function* () { const acp = yield* makeCursorAcpProbeRuntime(cursorSettings); const started = yield* acp.start(); @@ -452,7 +452,7 @@ const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => ); }).pipe(Effect.scoped); -const discoverCursorModelCapabilitiesViaAcp = ( +export const discoverCursorModelCapabilitiesViaAcp = ( cursorSettings: CursorSettings, existingModels: ReadonlyArray, ) => diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 27826a3571b..ae12d3112aa 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -164,6 +164,24 @@ describe("AcpRuntimeModel", () => { } }); + it("trims padded current mode updates before emitting a mode change", () => { + const result = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "current_mode_update", + currentModeId: " code ", + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(result.modeId).toBe("code"); + expect(result.events).toEqual([ + { + _tag: "ModeChanged", + modeId: "code", + }, + ]); + }); + it("projects typed ACP plan and content updates", () => { const planResult = parseSessionUpdateEvent({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 21f26f8e757..ffd214a5bf1 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -415,11 +415,13 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat switch (upd.sessionUpdate) { case "current_mode_update": { - modeId = upd.currentModeId; - events.push({ - _tag: "ModeChanged", - modeId, - }); + modeId = upd.currentModeId.trim(); + if (modeId) { + events.push({ + _tag: "ModeChanged", + modeId, + }); + } break; } case "plan": { diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 1b06b2f3464..27356f0b5c4 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -54,6 +54,31 @@ const enrichedSnapshot: ServerProvider = { ], }; +const refreshedSnapshotSecond: ServerProvider = { + ...refreshedSnapshot, + checkedAt: "2026-04-10T00:00:03.000Z", + message: "Refreshed provider availability again.", +}; + +const enrichedSnapshotSecond: ServerProvider = { + ...refreshedSnapshotSecond, + checkedAt: "2026-04-10T00:00:04.000Z", + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], +}; + describe("makeManagedServerProvider", () => { it.effect("keeps the initial snapshot until an explicit refresh runs", () => Effect.scoped( @@ -166,4 +191,59 @@ describe("makeManagedServerProvider", () => { }), ), ); + + it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () => + Effect.scoped( + Effect.gen(function* () { + const publishCallbacks: Array<(snapshot: ServerProvider) => Effect.Effect> = []; + const refreshCount = yield* Ref.make(0); + const firstCallbackReady = yield* Deferred.make(); + const secondCallbackReady = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => initialSnapshot, + checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( + Effect.map((count) => (count === 1 ? refreshedSnapshot : refreshedSnapshotSecond)), + ), + enrichSnapshot: ({ publishSnapshot }) => + Effect.gen(function* () { + publishCallbacks.push(publishSnapshot); + if (publishCallbacks.length === 1) { + yield* Deferred.succeed(firstCallbackReady, undefined).pipe(Effect.ignore); + } else if (publishCallbacks.length === 2) { + yield* Deferred.succeed(secondCallbackReady, undefined).pipe(Effect.ignore); + } + }), + refreshInterval: "1 hour", + }); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 3).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + yield* provider.refresh; + yield* Deferred.await(firstCallbackReady); + + yield* provider.refresh; + yield* Deferred.await(secondCallbackReady); + + yield* publishCallbacks[0]!(enrichedSnapshot); + yield* publishCallbacks[1]!(enrichedSnapshotSecond); + + const updates = Array.from(yield* Fiber.join(updatesFiber)); + const latest = yield* provider.getSnapshot; + + assert.deepStrictEqual(updates, [ + refreshedSnapshot, + refreshedSnapshotSecond, + enrichedSnapshotSecond, + ]); + assert.deepStrictEqual(latest, enrichedSnapshotSecond); + }), + ), + ); }); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 09cf7f644d1..7dc2cda2093 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -5,6 +5,11 @@ import * as Semaphore from "effect/Semaphore"; import type { ServerProviderShape } from "./Services/ServerProvider"; import { ServerSettingsError } from "@t3tools/contracts"; +interface ProviderSnapshotState { + readonly snapshot: ServerProvider; + readonly enrichmentGeneration: number; +} + export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < Settings, >(input: { @@ -28,33 +33,40 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ); const initialSettings = yield* input.getSettings; const initialSnapshot = input.initialSnapshot(initialSettings); - const snapshotRef = yield* Ref.make(initialSnapshot); + const snapshotStateRef = yield* Ref.make({ + snapshot: initialSnapshot, + enrichmentGeneration: 0, + }); const settingsRef = yield* Ref.make(initialSettings); const enrichmentFiberRef = yield* Ref.make | null>(null); - const enrichmentGenerationRef = yield* Ref.make(0); const scope = yield* Effect.scope; const publishEnrichedSnapshot = Effect.fn("publishEnrichedSnapshot")(function* ( generation: number, nextSnapshot: ServerProvider, ) { - const currentGeneration = yield* Ref.get(enrichmentGenerationRef); - if (currentGeneration !== generation) { - return; - } - - const previousSnapshot = yield* Ref.get(snapshotRef); - if (Equal.equals(previousSnapshot, nextSnapshot)) { + const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { + if (state.enrichmentGeneration !== generation || Equal.equals(state.snapshot, nextSnapshot)) { + return [null, state] as const; + } + return [ + nextSnapshot, + { + ...state, + snapshot: nextSnapshot, + }, + ] as const; + }); + if (snapshotToPublish === null) { return; } - - yield* Ref.set(snapshotRef, nextSnapshot); - yield* PubSub.publish(changesPubSub, nextSnapshot); + yield* PubSub.publish(changesPubSub, snapshotToPublish); }); const restartSnapshotEnrichment = Effect.fn("restartSnapshotEnrichment")(function* ( settings: Settings, snapshot: ServerProvider, + generation: number, ) { const previousFiber = yield* Ref.getAndSet(enrichmentFiberRef, null); if (previousFiber) { @@ -65,12 +77,11 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return; } - const generation = yield* Ref.updateAndGet(enrichmentGenerationRef, (value) => value + 1); const fiber = yield* input .enrichSnapshot({ settings, snapshot, - getSnapshot: Ref.get(snapshotRef), + getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), publishSnapshot: (nextSnapshot) => publishEnrichedSnapshot(generation, nextSnapshot), }) .pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(scope)); @@ -86,14 +97,25 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const previousSettings = yield* Ref.get(settingsRef); if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) { yield* Ref.set(settingsRef, nextSettings); - return yield* Ref.get(snapshotRef); + return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); } const nextSnapshot = yield* input.checkProvider; + const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => { + const generation = input.enrichSnapshot + ? state.enrichmentGeneration + 1 + : state.enrichmentGeneration; + return [ + generation, + { + snapshot: nextSnapshot, + enrichmentGeneration: generation, + }, + ] as const; + }); yield* Ref.set(settingsRef, nextSettings); - yield* Ref.set(snapshotRef, nextSnapshot); yield* PubSub.publish(changesPubSub, nextSnapshot); - yield* restartSnapshotEnrichment(nextSettings, nextSnapshot); + yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration); return nextSnapshot; }); const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index d41fec072c3..f688e7c86c8 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1185,6 +1185,24 @@ describe("composerDraftStore sticky composer settings", () => { expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex"); }); + it("drops empty cursor model options when normalizing sticky state", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModelSelection( + modelSelection("cursor", "gpt-5.4", { + reasoning: undefined, + fastMode: undefined, + thinking: undefined, + contextWindow: undefined, + }), + ); + + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toEqual( + modelSelection("cursor", "gpt-5.4"), + ); + expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor"); + }); + it("applies sticky activeProvider to new drafts", () => { const store = useComposerDraftStore.getState(); const threadId = ThreadId.make("thread-sticky-active-provider"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f59a313a31a..4cfa1e032b8 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -646,12 +646,15 @@ function normalizeProviderModelOptions( const cursor: CursorModelOptions | undefined = cursorCandidate !== null - ? { - ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), - ...(cursorFastMode !== undefined ? { fastMode: cursorFastMode } : {}), - ...(cursorThinking !== undefined ? { thinking: cursorThinking } : {}), - ...(cursorContextWindow !== undefined ? { contextWindow: cursorContextWindow } : {}), - } + ? (() => { + const nextCursor = { + ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), + ...(cursorFastMode !== undefined ? { fastMode: cursorFastMode } : {}), + ...(cursorThinking !== undefined ? { thinking: cursorThinking } : {}), + ...(cursorContextWindow !== undefined ? { contextWindow: cursorContextWindow } : {}), + } satisfies CursorModelOptions; + return Object.keys(nextCursor).length > 0 ? nextCursor : undefined; + })() : undefined; if (!codex && !claude && cursor === undefined) { diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts index 7e16aa870f2..90692ec4e1d 100644 --- a/packages/effect-acp/src/_internal/shared.ts +++ b/packages/effect-acp/src/_internal/shared.ts @@ -31,7 +31,7 @@ export const runHandler = Effect.fnUntraced(function* ( method: string, ) { if (!handler) { - return yield* AcpError.AcpRequestError.methodNotFound(method); + return yield* Effect.fail(AcpError.AcpRequestError.methodNotFound(method).toProtocolError()); } return yield* handler(payload).pipe( Effect.mapError((error) => diff --git a/packages/effect-acp/src/agent.test.ts b/packages/effect-acp/src/agent.test.ts index 15cba74725d..634cc6942df 100644 --- a/packages/effect-acp/src/agent.test.ts +++ b/packages/effect-acp/src/agent.test.ts @@ -32,6 +32,8 @@ const SessionCancelNotification = jsonRpcNotification( AcpSchema.CancelNotification, ); const ExtPingNotification = jsonRpcNotification("x/ping", Schema.Struct({ count: Schema.Number })); +const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); +const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); it.effect("effect-acp agent handles core agent requests and outbound client requests", () => Effect.gen(function* () { @@ -85,7 +87,6 @@ it.effect("effect-acp agent handles core agent requests and outbound client requ Schema.fromJsonString(RequestPermissionRequest), )(yield* Queue.take(output)); assert.equal(permissionRequest.jsonrpc, "2.0"); - assert.equal(permissionRequest.id, 1); assert.equal(permissionRequest.method, "session/request_permission"); assert.deepEqual(permissionRequest.params, { sessionId: "session-1", @@ -101,7 +102,7 @@ it.effect("effect-acp agent handles core agent requests and outbound client requ input, yield* encodeJsonl(RequestPermissionResponse, { jsonrpc: "2.0", - id: 1, + id: permissionRequest.id, result: { outcome: { outcome: "selected", @@ -177,3 +178,78 @@ it.effect("effect-acp agent handles core agent requests and outbound client requ }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); }), ); + +it.effect("effect-acp agent uses distinct ids for RPC calls and extension requests", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const context = yield* Layer.buildWithScope(AcpAgent.layer(stdio), scope); + + yield* Effect.gen(function* () { + const agent = yield* AcpAgent.AcpAgent; + + const permissionFiber = yield* agent.client + .requestPermission({ + sessionId: "session-1", + toolCall: { + toolCallId: "tool-1", + title: "Allow mock action", + }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }) + .pipe(Effect.forkScoped); + const extFiber = yield* agent.client + .extRequest("x/test", { hello: "world" }) + .pipe(Effect.forkScoped); + + const firstOutbound = yield* Queue.take(output); + const secondOutbound = yield* Queue.take(output); + + const decodedPermission = Schema.decodeEffect( + Schema.fromJsonString(RequestPermissionRequest), + ); + const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); + const firstIsPermission = yield* decodedPermission(firstOutbound).pipe( + Effect.match({ + onFailure: () => false, + onSuccess: () => true, + }), + ); + + const permissionRequest = firstIsPermission + ? yield* decodedPermission(firstOutbound) + : yield* decodedPermission(secondOutbound); + const extRequest = firstIsPermission + ? yield* decodedExt(secondOutbound) + : yield* decodedExt(firstOutbound); + + assert.notEqual(permissionRequest.id, extRequest.id); + + yield* Queue.offer( + input, + yield* encodeJsonl(RequestPermissionResponse, { + jsonrpc: "2.0", + id: permissionRequest.id, + result: { + outcome: { + outcome: "selected", + optionId: "allow", + }, + }, + }), + ); + yield* Queue.offer( + input, + yield* encodeJsonl(ExtResponse, { + jsonrpc: "2.0", + id: extRequest.id, + result: { ok: true }, + }), + ); + + const permission = yield* Fiber.join(permissionFiber); + assert.equal(permission.outcome.outcome, "selected"); + assert.deepEqual(yield* Fiber.join(extFiber), { ok: true }); + }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); + }), +); diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 98306acf471..e93257b7ec8 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -360,7 +360,7 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( Effect.forkScoped, ); - let nextRpcRequestId = 1n; + let nextRpcRequestId = 1n << 32n; const rpc = yield* RpcClient.make(AcpRpcs.ClientRpcs, { generateRequestId: () => nextRpcRequestId++ as never, }).pipe(Effect.provideService(RpcClient.Protocol, transport.clientProtocol)); diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 854fdba5ac5..c83509c2550 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -2,7 +2,9 @@ import * as Path from "effect/Path"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -13,6 +15,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; import * as AcpClient from "./client"; +import * as AcpSchema from "./_generated/schema.gen"; +import * as AcpError from "./errors"; +import { encodeJsonl, jsonRpcRequest, jsonRpcResponse } from "./_internal/shared"; +import { makeInMemoryStdio } from "./_internal/stdio"; + +const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest); +const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse); +const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); +const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), @@ -207,4 +218,233 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { assert.include(rendered, "Expected string, got 123"); }), ); + + it.effect("replays buffered notifications to handlers registered after they arrive", () => + Effect.gen(function* () { + const updates = yield* Ref.make>([]); + const elicitationCompletions = yield* Ref.make>([]); + const typedRequests = yield* Ref.make>([]); + const typedNotifications = yield* Ref.make>([]); + const handle = yield* makeHandle(); + const scope = yield* Scope.make(); + const acpLayer = AcpClient.layerChildProcess(handle); + const context = yield* Layer.buildWithScope(acpLayer, scope); + + yield* Effect.gen(function* () { + const acp = yield* AcpClient.AcpClient; + + yield* acp.handleRequestPermission(() => + Effect.succeed({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }), + ); + yield* acp.handleElicitation(() => + Effect.succeed({ + action: { + action: "accept", + content: { + approved: true, + }, + }, + }), + ); + yield* acp.handleExtRequest( + "x/typed_request", + Schema.Struct({ message: Schema.String }), + (payload) => + Ref.update(typedRequests, (current) => [...current, payload]).pipe( + Effect.as({ + ok: true, + echoedMessage: payload.message, + }), + ), + ); + yield* acp.handleExtNotification( + "x/typed_notification", + Schema.Struct({ count: Schema.Number }), + (payload) => Ref.update(typedNotifications, (current) => [...current, payload]), + ); + + yield* acp.agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: "effect-acp-test", + version: "0.0.0", + }, + }); + yield* acp.agent.authenticate({ methodId: "cursor_login" }); + + const session = yield* acp.agent.createSession({ + cwd: process.cwd(), + mcpServers: [], + }); + yield* acp.agent.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "hello" }], + }); + + yield* acp.handleSessionUpdate((notification) => + Ref.update(updates, (current) => [...current, notification]), + ); + yield* acp.handleElicitationComplete((notification) => + Ref.update(elicitationCompletions, (current) => [...current, notification]), + ); + + assert.equal((yield* Ref.get(updates)).length, 1); + assert.equal((yield* Ref.get(elicitationCompletions)).length, 1); + assert.deepEqual(yield* Ref.get(typedRequests), [{ message: "hello from typed request" }]); + assert.deepEqual(yield* Ref.get(typedNotifications), [{ count: 2 }]); + }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); + }), + ); + + it.effect("continues dispatching session updates after one handler fails", () => + Effect.gen(function* () { + const successfulHandlers = yield* Ref.make(0); + const handle = yield* makeHandle(); + const scope = yield* Scope.make(); + const acpLayer = AcpClient.layerChildProcess(handle); + const context = yield* Layer.buildWithScope(acpLayer, scope); + + yield* Effect.gen(function* () { + const acp = yield* AcpClient.AcpClient; + + yield* acp.handleRequestPermission(() => + Effect.succeed({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }), + ); + yield* acp.handleElicitation(() => + Effect.succeed({ + action: { + action: "accept", + content: { + approved: true, + }, + }, + }), + ); + yield* acp.handleExtRequest( + "x/typed_request", + Schema.Struct({ message: Schema.String }), + () => Effect.succeed({ ok: true }), + ); + yield* acp.handleExtNotification( + "x/typed_notification", + Schema.Struct({ count: Schema.Number }), + () => Effect.void, + ); + yield* acp.handleSessionUpdate(() => + Effect.fail(AcpError.AcpRequestError.internalError("session update handler failed")), + ); + yield* acp.handleSessionUpdate(() => Ref.update(successfulHandlers, (count) => count + 1)); + + yield* acp.agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: "effect-acp-test", + version: "0.0.0", + }, + }); + yield* acp.agent.authenticate({ methodId: "cursor_login" }); + + const session = yield* acp.agent.createSession({ + cwd: process.cwd(), + mcpServers: [], + }); + yield* acp.agent.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "hello" }], + }); + + assert.equal(yield* Ref.get(successfulHandlers), 1); + }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); + }), + ); + + it.effect("uses distinct ids for RPC calls and extension requests", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope)); + + const initializeFiber = yield* acp.agent + .initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: "effect-acp-test", + version: "0.0.0", + }, + }) + .pipe(Effect.forkScoped); + const extFiber = yield* acp.raw.request("x/test", { hello: "world" }).pipe(Effect.forkScoped); + + const firstOutbound = yield* Queue.take(output); + const secondOutbound = yield* Queue.take(output); + + const decodedInitialize = Schema.decodeEffect(Schema.fromJsonString(InitializeRequest)); + const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); + const firstIsInitialize = yield* decodedInitialize(firstOutbound).pipe( + Effect.match({ + onFailure: () => false, + onSuccess: () => true, + }), + ); + + const initializeRequest = firstIsInitialize + ? yield* decodedInitialize(firstOutbound) + : yield* decodedInitialize(secondOutbound); + const extRequest = firstIsInitialize + ? yield* decodedExt(secondOutbound) + : yield* decodedExt(firstOutbound); + + assert.notEqual(initializeRequest.id, extRequest.id); + + yield* Queue.offer( + input, + yield* encodeJsonl(InitializeResponse, { + jsonrpc: "2.0", + id: initializeRequest.id, + result: { + protocolVersion: 1, + agentCapabilities: {}, + agentInfo: { + name: "mock-agent", + version: "0.0.0", + }, + }, + }), + ); + yield* Queue.offer( + input, + yield* encodeJsonl(ExtResponse, { + jsonrpc: "2.0", + id: extRequest.id, + result: { ok: true }, + }), + ); + + yield* Fiber.join(initializeFiber); + assert.deepEqual(yield* Fiber.join(extFiber), { ok: true }); + yield* Scope.close(scope, Exit.void); + }), + ); }); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 7a321142012..6aa0d036460 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -297,14 +297,13 @@ interface AcpCoreRequestHandlers { } interface AcpNotificationHandlers { - readonly sessionUpdate: Array< - (notification: AcpSchema.SessionNotification) => Effect.Effect - >; - readonly elicitationComplete: Array< - ( - notification: AcpSchema.ElicitationCompleteNotification, - ) => Effect.Effect - >; + readonly sessionUpdate: BufferedNotificationHandler; + readonly elicitationComplete: BufferedNotificationHandler; +} + +interface BufferedNotificationHandler { + readonly handlers: Array<(notification: A) => Effect.Effect>; + readonly pending: Array; } export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( @@ -314,8 +313,8 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( ): Effect.fn.Return { const coreHandlers: AcpCoreRequestHandlers = {}; const notificationHandlers: AcpNotificationHandlers = { - sessionUpdate: [], - elicitationComplete: [], + sessionUpdate: { handlers: [], pending: [] }, + elicitationComplete: { handlers: [], pending: [] }, }; const extRequestHandlers = new Map< string, @@ -332,20 +331,50 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( | ((method: string, params: unknown) => Effect.Effect) | undefined; + const runNotificationHandlers = ( + registration: BufferedNotificationHandler, + notification: A, + ) => + Effect.forEach( + registration.handlers, + (handler) => handler(notification).pipe(Effect.catch(() => Effect.void)), + { discard: true }, + ); + + const flushBufferedNotifications = (registration: BufferedNotificationHandler) => + Effect.suspend(() => { + if (registration.handlers.length === 0 || registration.pending.length === 0) { + return Effect.void; + } + const pending = registration.pending.splice(0, registration.pending.length); + return Effect.forEach( + pending, + (notification) => runNotificationHandlers(registration, notification), + { + discard: true, + }, + ); + }); + const dispatchNotification = (notification: AcpProtocol.AcpIncomingNotification) => { switch (notification._tag) { - case "SessionUpdate": - return Effect.forEach( - notificationHandlers.sessionUpdate, - (handler) => handler(notification.params), - { discard: true }, - ); - case "ElicitationComplete": - return Effect.forEach( + case "SessionUpdate": { + if (notificationHandlers.sessionUpdate.handlers.length === 0) { + notificationHandlers.sessionUpdate.pending.push(notification.params); + return Effect.void; + } + return runNotificationHandlers(notificationHandlers.sessionUpdate, notification.params); + } + case "ElicitationComplete": { + if (notificationHandlers.elicitationComplete.handlers.length === 0) { + notificationHandlers.elicitationComplete.pending.push(notification.params); + return Effect.void; + } + return runNotificationHandlers( notificationHandlers.elicitationComplete, - (handler) => handler(notification.params), - { discard: true }, + notification.params, ); + } case "ExtNotification": { const handler = extNotificationHandlers.get(notification.method); if (handler) { @@ -422,7 +451,7 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( Effect.forkScoped, ); - let nextRpcRequestId = 1n; + let nextRpcRequestId = 1n << 32n; const rpc = yield* RpcClient.make(AcpRpcs.AgentRpcs, { generateRequestId: () => nextRpcRequestId++ as never, }).pipe(Effect.provideService(RpcClient.Protocol, transport.clientProtocol)); @@ -496,13 +525,13 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( }), handleSessionUpdate: (handler) => Effect.suspend(() => { - notificationHandlers.sessionUpdate.push(handler); - return Effect.void; + notificationHandlers.sessionUpdate.handlers.push(handler); + return flushBufferedNotifications(notificationHandlers.sessionUpdate); }), handleElicitationComplete: (handler) => Effect.suspend(() => { - notificationHandlers.elicitationComplete.push(handler); - return Effect.void; + notificationHandlers.elicitationComplete.handlers.push(handler); + return flushBufferedNotifications(notificationHandlers.elicitationComplete); }), handleUnknownExtRequest: (handler) => Effect.suspend(() => { diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index a1358451952..f8f484ffff5 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -159,13 +159,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi resolveExtPending(requestId, (deferred) => Deferred.succeed(deferred, value)); const failAllExtPending = (error: AcpError.AcpError) => - Ref.get(extPending).pipe( + Ref.getAndSet(extPending, new Map()).pipe( Effect.flatMap((pending) => Effect.forEach([...pending.values()], (deferred) => Deferred.fail(deferred, error), { discard: true, }), ), - Effect.andThen(Ref.set(extPending, new Map())), ); const dispatchNotification = (notification: AcpIncomingNotification) => From ce947ec3a1d2a83fa3ed19a358be982360b3fb1e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 13:19:06 -0700 Subject: [PATCH 58/82] Fix validation fallout after main merge Co-authored-by: codex --- apps/server/src/provider/Layers/ClaudeAdapter.ts | 8 +++++++- apps/web/src/composerDraftStore.ts | 1 - 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 4d8ce297697..6e415fc2395 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2765,7 +2765,13 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(apiModelId ? { model: apiModelId } : {}), pathToClaudeCodeExecutable: claudeBinaryPath, settingSources: [...CLAUDE_SETTING_SOURCES], - ...(effectiveEffort ? { effort: effectiveEffort } : {}), + // The SDK type lags the CLI here: Opus 4.7 accepts `xhigh` even though + // the published `Options["effort"]` union currently stops at `max`. + ...(effectiveEffort + ? { + effort: effectiveEffort as unknown as NonNullable, + } + : {}), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 9052570549a..79a3ccf0278 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,7 +1,6 @@ import { CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, - type CodexReasoningEffort, type CursorModelOptions, type CursorReasoningOption, ClaudeAgentEffort, From eb4f92381e982c24fbba7b3eddad66b841ede4d1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 14:07:44 -0700 Subject: [PATCH 59/82] Harden Cursor ACP probe cleanup and warning handling - Scope ACP probe runtimes so they close reliably - Surface discovery failures as provider warnings - Add coverage for cleanup and snapshot behavior --- .../provider/Layers/CursorProvider.test.ts | 146 +++++++++- .../src/provider/Layers/CursorProvider.ts | 270 +++++++++++------- 2 files changed, 313 insertions(+), 103 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 23975657930..be90e3c8569 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1,16 +1,19 @@ import * as path from "node:path"; import * as os from "node:os"; -import { chmod, mkdtemp, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; import type * as EffectAcpSchema from "effect-acp/schema"; +import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts"; import { + buildCursorProviderSnapshot, buildCursorCapabilitiesFromConfigOptions, buildCursorDiscoveredModelsFromConfigOptions, + discoverCursorModelCapabilitiesViaAcp, discoverCursorModelsViaAcp, getCursorFallbackModels, getCursorParameterizedModelPickerUnsupportedMessage, @@ -39,6 +42,19 @@ exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@" return wrapperPath; } +async function waitForFileContent(filePath: string, attempts = 40): Promise { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const content = await readFile(filePath, "utf8"); + if (content.trim().length > 0) { + return content; + } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Timed out waiting for file content at ${filePath}`); +} + const parameterizedGpt54ConfigOptions = [ { type: "select", @@ -216,6 +232,21 @@ const sessionNewCursorConfigOptions = [ }, ] satisfies ReadonlyArray; +const baseCursorSettings: CursorSettings = { + enabled: true, + binaryPath: "agent", + apiEndpoint: "", + customModels: [], +}; + +const emptyCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +} as const; + describe("getCursorFallbackModels", () => { it("does not publish any built-in cursor models before ACP discovery", () => { expect( @@ -226,6 +257,56 @@ describe("getCursorFallbackModels", () => { }); }); +describe("buildCursorProviderSnapshot", () => { + it("downgrades ready status to warning when ACP model discovery times out", () => { + expect( + buildCursorProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + cursorSettings: baseCursorSettings, + parsed: { + version: "2026.04.09-f2b0fcd", + status: "ready", + auth: { status: "authenticated", type: "Team", label: "Cursor Team Subscription" }, + }, + discoveryWarning: "Cursor ACP model discovery timed out after 15000ms.", + }), + ).toMatchObject({ + status: "warning", + message: "Cursor ACP model discovery timed out after 15000ms.", + models: [], + }); + }); + + it("preserves provider error state while appending discovery warnings", () => { + expect( + buildCursorProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + cursorSettings: { + ...baseCursorSettings, + customModels: ["claude-sonnet-4-6"], + }, + parsed: { + version: "2026.04.09-f2b0fcd", + status: "error", + auth: { status: "unauthenticated" }, + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }, + discoveryWarning: "Cursor ACP model discovery failed. Check server logs for details.", + }), + ).toMatchObject({ + status: "error", + message: + "Cursor Agent is not authenticated. Run `agent login` and try again. Cursor ACP model discovery failed. Check server logs for details.", + models: [ + { + slug: "claude-sonnet-4-6", + isCustom: true, + }, + ], + }); + }); +}); + describe("buildCursorCapabilitiesFromConfigOptions", () => { it("derives model capabilities from parameterized Cursor ACP config options", () => { expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual({ @@ -376,6 +457,69 @@ describe("discoverCursorModelsViaAcp", () => { "claude-opus-4-6", ]); }); + + it("closes the ACP probe runtime after discovery completes", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-exit-log-")); + const exitLogPath = path.join(tempDir, "exit.log"); + const wrapperPath = await makeMockAgentWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }); + + await Effect.runPromise( + discoverCursorModelsViaAcp({ + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }).pipe(Effect.provide(NodeServices.layer)), + ); + + const exitLog = await waitForFileContent(exitLogPath); + expect(exitLog).toContain("SIGTERM"); + }); +}); + +describe("discoverCursorModelCapabilitiesViaAcp", () => { + it("closes all ACP probe runtimes after capability enrichment completes", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-capabilities-exit-log-")); + const exitLogPath = path.join(tempDir, "exit.log"); + const wrapperPath = await makeMockAgentWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }); + const existingModels: ReadonlyArray = [ + { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities }, + { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities }, + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: emptyCapabilities }, + { + slug: "claude-opus-4-6", + name: "Opus 4.6", + isCustom: false, + capabilities: emptyCapabilities, + }, + ]; + + const models = await Effect.runPromise( + discoverCursorModelCapabilitiesViaAcp( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + existingModels, + ).pipe(Effect.provide(NodeServices.layer)), + ); + + expect(models.map((model) => model.slug)).toEqual([ + "default", + "composer-2", + "gpt-5.4", + "claude-opus-4-6", + ]); + + const exitLog = await waitForFileContent(exitLogPath); + expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4); + }); }); describe("parseCursorAboutOutput", () => { diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 11875d0cf20..ef686854896 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -13,7 +13,7 @@ import type { ServerSettingsError, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; +import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { @@ -342,6 +342,16 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); }); +const withCursorAcpProbeRuntime = ( + cursorSettings: CursorSettings, + useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, +) => + Effect.gen(function* () { + const acp = yield* makeCursorAcpProbeRuntime(cursorSettings); + yield* Effect.addFinalizer(() => acp.close); + return yield* useRuntime(acp); + }).pipe(Effect.scoped); + function normalizeCursorConfigOptionToken(value: string | null | undefined): string { return ( value @@ -444,44 +454,104 @@ export function resolveCursorAcpConfigUpdates( } export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => - Effect.gen(function* () { - const acp = yield* makeCursorAcpProbeRuntime(cursorSettings); - const started = yield* acp.start(); - return buildCursorDiscoveredModelsFromConfigOptions( - started.sessionSetupResult.configOptions ?? [], - ); - }).pipe(Effect.scoped); + withCursorAcpProbeRuntime(cursorSettings, (acp) => + Effect.gen(function* () { + const started = yield* acp.start(); + return buildCursorDiscoveredModelsFromConfigOptions( + started.sessionSetupResult.configOptions ?? [], + ); + }), + ); export const discoverCursorModelCapabilitiesViaAcp = ( cursorSettings: CursorSettings, existingModels: ReadonlyArray, ) => - Effect.gen(function* () { - const acp = yield* makeCursorAcpProbeRuntime(cursorSettings); - const started = yield* acp.start(); - const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; - const modelOption = findCursorModelConfigOption(initialConfigOptions); - const modelChoices = flattenSessionConfigSelectOptions(modelOption); - if (!modelOption || modelChoices.length === 0) { - return []; - } + withCursorAcpProbeRuntime(cursorSettings, (acp) => + Effect.gen(function* () { + const started = yield* acp.start(); + const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; + const modelOption = findCursorModelConfigOption(initialConfigOptions); + const modelChoices = flattenSessionConfigSelectOptions(modelOption); + if (!modelOption || modelChoices.length === 0) { + return []; + } + + const currentModelValue = + modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; + const capabilitiesBySlug = new Map(); + if (currentModelValue) { + capabilitiesBySlug.set( + currentModelValue, + buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), + ); + } - const currentModelValue = - modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; - const capabilitiesBySlug = new Map(); - if (currentModelValue) { - capabilitiesBySlug.set( - currentModelValue, - buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), + const targetModelSlugs = new Set( + existingModels + .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model)) + .map((model) => model.slug), ); - } + if (targetModelSlugs.size === 0) { + return buildCursorDiscoveredModels( + modelChoices.map((modelChoice) => ({ + slug: modelChoice.value.trim(), + name: modelChoice.name.trim(), + capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, + })), + ); + } + + const probedCapabilities = yield* Effect.forEach( + modelChoices, + (modelChoice) => { + const modelSlug = modelChoice.value.trim(); + if (!modelSlug || !targetModelSlugs.has(modelSlug) || capabilitiesBySlug.has(modelSlug)) { + return Effect.void.pipe( + Effect.as(undefined), + ); + } + + return withCursorAcpProbeRuntime(cursorSettings, (probeAcp) => + Effect.gen(function* () { + const probeStarted = yield* probeAcp.start(); + const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? []; + const probeModelOption = findCursorModelConfigOption(probeConfigOptions); + const probeCurrentModelValue = + probeModelOption?.type === "select" + ? probeModelOption.currentValue?.trim() || undefined + : undefined; + const nextConfigOptions = + probeCurrentModelValue === modelSlug + ? probeConfigOptions + : yield* probeAcp + .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug) + .pipe(Effect.map((response) => response.configOptions ?? probeConfigOptions)); + return [ + modelSlug, + buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), + ] as const; + }), + ).pipe( + Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), + Effect.catchCause((cause) => + Effect.logWarning("Cursor ACP capability probe failed", { + modelSlug, + cause: Cause.pretty(cause), + }).pipe(Effect.as(undefined)), + ), + ); + }, + { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, + ); + + for (const entry of probedCapabilities) { + if (!entry) { + continue; + } + capabilitiesBySlug.set(entry[0], entry[1]); + } - const targetModelSlugs = new Set( - existingModels - .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model)) - .map((model) => model.slug), - ); - if (targetModelSlugs.size === 0) { return buildCursorDiscoveredModels( modelChoices.map((modelChoice) => ({ slug: modelChoice.value.trim(), @@ -489,55 +559,8 @@ export const discoverCursorModelCapabilitiesViaAcp = ( capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, })), ); - } - - const probedCapabilities = yield* Effect.forEach( - modelChoices, - (modelChoice) => { - const modelSlug = modelChoice.value.trim(); - if (!modelSlug || !targetModelSlugs.has(modelSlug) || capabilitiesBySlug.has(modelSlug)) { - return Effect.void; - } - - return Effect.gen(function* () { - const probeAcp = yield* makeCursorAcpProbeRuntime(cursorSettings); - const probeStarted = yield* probeAcp.start(); - const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? []; - const probeModelOption = findCursorModelConfigOption(probeConfigOptions); - const probeCurrentModelValue = - probeModelOption?.type === "select" - ? probeModelOption.currentValue?.trim() || undefined - : undefined; - const nextConfigOptions = - probeCurrentModelValue === modelSlug - ? probeConfigOptions - : yield* probeAcp - .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug) - .pipe(Effect.map((response) => response.configOptions ?? probeConfigOptions)); - return [modelSlug, buildCursorCapabilitiesFromConfigOptions(nextConfigOptions)] as const; - }).pipe( - Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), - Effect.catch(() => Effect.void), - ); - }, - { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, - ); - - for (const entry of probedCapabilities) { - if (!entry) { - continue; - } - capabilitiesBySlug.set(entry[0], entry[1]); - } - - return buildCursorDiscoveredModels( - modelChoices.map((modelChoice) => ({ - slug: modelChoice.value.trim(), - name: modelChoice.name.trim(), - capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, - })), - ); - }).pipe(Effect.scoped); + }), + ); export function getCursorFallbackModels( cursorSettings: Pick, @@ -571,6 +594,42 @@ export interface CursorAboutResult { readonly message?: string; } +function joinProviderMessages(...messages: ReadonlyArray): string | undefined { + const parts = messages + .map((message) => message?.trim()) + .filter((message): message is string => Boolean(message)); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +export function buildCursorProviderSnapshot(input: { + readonly checkedAt: string; + readonly cursorSettings: CursorSettings; + readonly parsed: CursorAboutResult; + readonly discoveredModels?: ReadonlyArray; + readonly discoveryWarning?: string; +}): ServerProvider { + const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); + return buildServerProvider({ + provider: PROVIDER, + enabled: input.cursorSettings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + input.discoveredModels ?? [], + PROVIDER, + input.cursorSettings.customModels, + EMPTY_CAPABILITIES, + ), + probe: { + installed: true, + version: input.parsed.version, + status: + input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, + auth: input.parsed.auth, + ...(message ? { message } : {}), + }, + }); +} + interface CursorAboutJsonPayload { readonly cliVersion?: unknown; readonly subscriptionTier?: unknown; @@ -983,33 +1042,35 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( }); } let discoveredModels = Option.none>(); + let discoveryWarning: string | undefined; if (parsed.auth.status !== "unauthenticated") { - discoveredModels = yield* discoverCursorModelsViaAcp(cursorSettings).pipe( - Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - Effect.catch(() => Effect.succeed(Option.none>())), + const discoveryExit = yield* Effect.exit( + discoverCursorModelsViaAcp(cursorSettings).pipe( + Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + ), ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Cursor ACP model discovery failed", { + cause: Cause.pretty(discoveryExit.cause), + }); + discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; + } else if (Option.isNone(discoveryExit.value)) { + discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; + } else if (discoveryExit.value.value.length === 0) { + discoveryWarning = "Cursor ACP model discovery returned no built-in models."; + } else { + discoveredModels = discoveryExit.value; + } } - const models = providerModelsFromSettings( - Option.getOrElse( + return buildCursorProviderSnapshot({ + checkedAt, + cursorSettings, + parsed, + discoveredModels: Option.getOrElse( Option.filter(discoveredModels, (models) => models.length > 0), () => [] as const, ), - PROVIDER, - cursorSettings.customModels, - EMPTY_CAPABILITIES, - ); - return buildServerProvider({ - provider: PROVIDER, - enabled: cursorSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsed.version, - status: parsed.status, - auth: parsed.auth, - ...(parsed.message ? { message: parsed.message } : {}), - }, + ...(discoveryWarning ? { discoveryWarning } : {}), }); }, ); @@ -1062,7 +1123,12 @@ export const CursorProviderLive = Layer.effect( ), }); }), - Effect.catch(() => Effect.void), + Effect.catchCause((cause) => + Effect.logWarning("Cursor ACP background capability enrichment failed", { + models: snapshot.models.map((model) => model.slug), + cause: Cause.pretty(cause), + }).pipe(Effect.asVoid), + ), ); }, refreshInterval: CURSOR_REFRESH_INTERVAL, From d9657e6cd9cb455c45faf2a2c2e25e8072cda34b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 14:34:06 -0700 Subject: [PATCH 60/82] Scope ACP runtime to session lifecycle - Close Cursor ACP child processes when a session stops - Remove explicit runtime.close plumbing in favor of scoped cleanup --- .../src/git/Layers/CursorTextGeneration.ts | 1 - .../src/provider/Layers/CursorAdapter.test.ts | 45 +++++++++++++++++++ .../src/provider/Layers/CursorAdapter.ts | 15 ++++++- .../src/provider/Layers/CursorProvider.ts | 7 +-- .../provider/acp/AcpJsonRpcConnection.test.ts | 14 ------ .../src/provider/acp/AcpSessionRuntime.ts | 7 +-- .../provider/acp/CursorAcpCliProbe.test.ts | 3 -- .../src/provider/acp/CursorAcpSupport.ts | 6 +-- 8 files changed, 64 insertions(+), 34 deletions(-) diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts index 6bf907dfca7..754f3737eb5 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -132,7 +132,6 @@ const makeCursorTextGeneration = Effect.gen(function* () { cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, }); - yield* Effect.addFinalizer(() => runtime.close); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index cf4bf52e689..3d36485e6ca 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -73,6 +73,19 @@ async function readJsonLines(filePath: string) { .map((line) => JSON.parse(line) as Record); } +async function waitForFileContent(filePath: string, attempts = 40) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const raw = await readFile(filePath, "utf8"); + if (raw.trim().length > 0) { + return raw; + } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Timed out waiting for file content at ${filePath}`); +} + const cursorAdapterTestLayer = it.layer( makeCursorAdapterLive().pipe( Layer.provideMerge(ServerSettingsService.layerTest()), @@ -168,6 +181,38 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect("closes the ACP child process when a session stops", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-stop-session-close"); + const tempDir = yield* Effect.promise(() => + mkdtemp(path.join(os.tmpdir(), "cursor-adapter-exit-log-")), + ); + const exitLogPath = path.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }); + + yield* adapter.stopSession(threadId); + + const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); + assert.include(exitLog, "SIGTERM"); + }), + ); + it.effect("rejects startSession when provider mismatches", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 601d162c048..0f75aab171d 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -22,11 +22,13 @@ import { DateTime, Deferred, Effect, + Exit, Fiber, FileSystem, Layer, PubSub, Random, + Scope, Stream, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -93,6 +95,7 @@ interface PendingUserInput { interface CursorSessionContext { readonly threadId: ThreadId; session: ProviderSession; + readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntimeShape; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; @@ -330,7 +333,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { if (ctx.notificationFiber) { yield* Fiber.interrupt(ctx.notificationFiber); } - yield* Effect.ignore(ctx.acp.close); + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); sessions.delete(ctx.threadId); yield* offerRuntimeEvent({ type: "session.exited", @@ -381,6 +384,11 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const pendingApprovals = new Map(); const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); let ctx!: CursorSessionContext; const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; @@ -398,6 +406,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { clientInfo: { name: "t3-code", version: "0.0.0" }, ...acpNativeLoggers, }).pipe( + Effect.provideService(Scope.Scope, sessionScope), Effect.mapError( (cause) => new ProviderAdapterProcessError({ @@ -582,6 +591,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ctx = { threadId: input.threadId, session, + scope: sessionScope, acp, notificationFiber: undefined, pendingApprovals, @@ -666,6 +676,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; yield* offerRuntimeEvent({ type: "session.started", @@ -690,7 +701,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); return session; - }); + }).pipe(Effect.scoped); const sendTurn: CursorAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index ef686854896..f966ba2be82 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -345,12 +345,7 @@ const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, -) => - Effect.gen(function* () { - const acp = yield* makeCursorAcpProbeRuntime(cursorSettings); - yield* Effect.addFinalizer(() => acp.close); - return yield* useRuntime(acp); - }).pipe(Effect.scoped); +) => makeCursorAcpProbeRuntime(cursorSettings).pipe(Effect.flatMap(useRuntime), Effect.scoped); function normalizeCursorConfigOptionToken(value: string | null | undefined): string { return ( diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b91dcb67180..fcfe5ad846b 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -33,8 +33,6 @@ describe("AcpSessionRuntime", () => { _meta: { parameterizedModelPicker: true }, }, }); - - yield* runtime.close; }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -95,8 +93,6 @@ describe("AcpSessionRuntime", () => { ) { expect(assistantDelta.itemId).toBe(assistantStart.itemId); } - - yield* runtime.close; }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -155,8 +151,6 @@ describe("AcpSessionRuntime", () => { expect(secondStarted.itemId).not.toBe(firstStarted.itemId); expect(secondDelta.itemId).toBe(secondStarted.itemId); } - - yield* runtime.close; }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -195,8 +189,6 @@ describe("AcpSessionRuntime", () => { expect(toolCall.toolCall.status).toBe("completed"); expect(toolCall.toolCall.title).toBe("Read file"); } - - yield* runtime.close; }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -248,8 +240,6 @@ describe("AcpSessionRuntime", () => { (event) => event.method === "session/prompt" && event.status === "succeeded", ), ).toBe(true); - - yield* runtime.close; }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -293,8 +283,6 @@ describe("AcpSessionRuntime", () => { expect( protocolEvents.some((event) => event.direction === "incoming" && event.stage === "decoded"), ).toBe(true); - - yield* runtime.close; }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -337,8 +325,6 @@ describe("AcpSessionRuntime", () => { expect(error.message).toContain("composer-2[fast=true]"); } - yield* runtime.close; - const recordedRequests = readFileSync(requestLogPath, "utf8") .trim() .split("\n") diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 04ea71efabf..15ffad98e05 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -100,7 +100,6 @@ export interface AcpSessionRuntimeShape { method: string, payload: unknown, ) => Effect.Effect; - readonly close: Effect.Effect; } interface AcpStartedState extends AcpSessionRuntimeStartResult {} @@ -142,11 +141,11 @@ const makeAcpSessionRuntime = ( ): Effect.Effect< AcpSessionRuntimeShape, EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeScope = yield* Scope.make("sequential"); + const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); @@ -227,7 +226,6 @@ const makeAcpSessionRuntime = ( params: notification, }), ); - const close = Scope.close(runtimeScope, Exit.void).pipe(Effect.asVoid); const initializeClientCapabilities = { fs: { @@ -513,7 +511,6 @@ const makeAcpSessionRuntime = ( request: (method, payload) => runLoggedRequest(method, payload, acp.raw.request(method, payload)), notify: acp.raw.notify, - close, } satisfies AcpSessionRuntimeShape; }); diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index f8d22533514..7744e24ac97 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -16,7 +16,6 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", const runtime = yield* AcpSessionRuntime; const started = yield* runtime.start(); expect(started.initializeResult).toBeDefined(); - yield* runtime.close; }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -68,7 +67,6 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", expect(modelConfig).toBeDefined(); expect(typeof modelConfig?.id).toBe("string"); } - yield* runtime.close; }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -125,7 +123,6 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", } expect(parameterizedOptions.length).toBeGreaterThan(0); } - yield* runtime.close; }).pipe( Effect.provide( AcpSessionRuntime.layer({ diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 9634cc12d67..72b9af394b3 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -1,5 +1,5 @@ import { type CursorModelOptions, type CursorSettings } from "@t3tools/contracts"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Scope } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpErrors from "effect-acp/errors"; @@ -47,7 +47,7 @@ export function buildCursorAcpSpawnInput( export const makeCursorAcpRuntime = ( input: CursorAcpRuntimeInput, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ @@ -62,7 +62,7 @@ export const makeCursorAcpRuntime = ( ), ); return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); - }).pipe(Effect.scoped); + }); interface CursorAcpModelSelectionRuntime { readonly getConfigOptions: AcpSessionRuntimeShape["getConfigOptions"]; From a1a8b0d822ae519916358bf81d82e3c9f421412c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 14:45:09 -0700 Subject: [PATCH 61/82] Serialize same-thread Cursor session startup - Guard startSession and stopSession with per-thread locks - Close replaced ACP sessions deterministically under concurrency - Ensure ACP runtime test cleanup runs on scope exit --- .../src/provider/Layers/CursorAdapter.test.ts | 58 +- .../src/provider/Layers/CursorAdapter.ts | 717 ++++++++++-------- .../provider/acp/AcpJsonRpcConnection.test.ts | 3 +- 3 files changed, 442 insertions(+), 336 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 3d36485e6ca..bdb47a229c5 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -18,7 +18,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); const bunExe = "bun"; -async function makeMockAgentWrapper(extraEnv?: Record) { +async function makeMockAgentWrapper( + extraEnv?: Record, + options?: { initialDelaySeconds?: number }, +) { const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-mock-")); const wrapperPath = path.join(dir, "fake-agent.sh"); const envExports = Object.entries(extraEnv ?? {}) @@ -26,6 +29,7 @@ async function makeMockAgentWrapper(extraEnv?: Record) { .join("\n"); const script = `#!/bin/sh ${envExports} +${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""} exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@" `; await writeFile(wrapperPath, script, "utf8"); @@ -213,6 +217,58 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect( + "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", + () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-concurrent-start-session"); + const tempDir = yield* Effect.promise(() => + mkdtemp(path.join(os.tmpdir(), "cursor-adapter-concurrent-exit-log-")), + ); + const exitLogPath = path.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper( + { + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }, + { initialDelaySeconds: 0.2 }, + ), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const [firstSession, secondSession] = yield* Effect.all( + [ + adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }), + adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }), + ], + { concurrency: "unbounded" }, + ); + + assert.equal(firstSession.threadId, threadId); + assert.equal(secondSession.threadId, threadId); + + yield* adapter.stopSession(threadId); + + const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); + assert.equal(exitLog.match(/SIGTERM/g)?.length ?? 0, 2); + }), + ); + it.effect("rejects startSession when provider mismatches", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 0f75aab171d..c71c0b39c57 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -26,10 +26,13 @@ import { Fiber, FileSystem, Layer, + Option, PubSub, Random, Scope, + Semaphore, Stream, + SynchronizedRef, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -244,6 +247,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -253,6 +257,27 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + const logNative = ( threadId: ThreadId, method: string, @@ -345,363 +370,386 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); const startSession: CursorAdapterShape["startSession"] = (input) => - Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }); - } - if (!input.cwd?.trim()) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: "cwd is required and must be non-empty.", - }); - } + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } - const cwd = nodePath.resolve(input.cwd.trim()); - const cursorModelSelection = - input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; - const existing = sessions.get(input.threadId); - if (existing && !existing.stopped) { - yield* stopSessionInternal(existing); - } + const cwd = nodePath.resolve(input.cwd.trim()); + const cursorModelSelection = + input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } - const cursorSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.providers.cursor), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); + const cursorSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.cursor), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); - const pendingApprovals = new Map(); - const pendingUserInputs = new Map(); - const sessionScope = yield* Scope.make("sequential"); - let sessionScopeTransferred = false; - yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), - ); - let ctx!: CursorSessionContext; + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: CursorSessionContext; - const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; - const acpNativeLoggers = makeAcpNativeLoggers({ - nativeEventLogger, - provider: PROVIDER, - threadId: input.threadId, - }); + const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); - const acp = yield* makeCursorAcpRuntime({ - cursorSettings, - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...acpNativeLoggers, - }).pipe( - Effect.provideService(Scope.Scope, sessionScope), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: cause.message, - cause, - }), - ), - ); - const started = yield* Effect.gen(function* () { - yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", - ); - const requestId = ApprovalRequestId.make(crypto.randomUUID()); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - payload: { questions: extractAskQuestions(params) }, - raw: { - source: "acp.cursor.extension", - method: "cursor/ask_question", - payload: params, - }, - }); - const resolved = yield* Deferred.await(answers); - pendingUserInputs.delete(requestId); - yield* offerRuntimeEvent({ - type: "user-input.resolved", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - payload: { answers: resolved }, - }); - return { answers: resolved }; - }), - ); - yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/create_plan", - params, - "acp.cursor.extension", - ); - yield* offerRuntimeEvent({ - type: "turn.proposed.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - payload: { planMarkdown: extractPlanMarkdown(params) }, - raw: { - source: "acp.cursor.extension", - method: "cursor/create_plan", - payload: params, - }, - }); - return { accepted: true } as const; - }), + const acp = yield* makeCursorAcpRuntime({ + cursorSettings, + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), ); - yield* acp.handleExtNotification( - "cursor/update_todos", - CursorUpdateTodosRequest, - (params) => + const started = yield* Effect.gen(function* () { + yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => Effect.gen(function* () { yield* logNative( input.threadId, - "cursor/update_todos", + "cursor/ask_question", params, "acp.cursor.extension", ); - if (ctx) { - yield* emitPlanUpdate( - ctx, - extractTodosAsPlan(params), - params, - "acp.cursor.extension", - "cursor/update_todos", - ); - } - }), - ); - yield* acp.handleRequestPermission((params) => - Effect.gen(function* () { - yield* logNative(input.threadId, "session/request_permission", params, "acp.jsonrpc"); - if (input.runtimeMode === "full-access") { - const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); - if (autoApprovedOptionId !== undefined) { - return { - outcome: { - outcome: "selected" as const, - optionId: autoApprovedOptionId, - }, - }; - } - } - const permissionRequest = parsePermissionRequest(params); - const requestId = ApprovalRequestId.make(crypto.randomUUID()); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const decision = yield* Deferred.make(); - pendingApprovals.set(requestId, { - decision, - kind: permissionRequest.kind, - }); - yield* offerRuntimeEvent( - makeAcpRequestOpenedEvent({ - stamp: yield* makeEventStamp(), + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const answers = yield* Deferred.make(); + pendingUserInputs.set(requestId, { answers }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, - permissionRequest, - detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000), - args: params, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: params, - }), - ); - const resolved = yield* Deferred.await(decision); - pendingApprovals.delete(requestId); - yield* offerRuntimeEvent( - makeAcpRequestResolvedEvent({ - stamp: yield* makeEventStamp(), + payload: { questions: extractAskQuestions(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/ask_question", + payload: params, + }, + }); + const resolved = yield* Deferred.await(answers); + pendingUserInputs.delete(requestId); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, turnId: ctx?.activeTurnId, requestId: runtimeRequestId, - permissionRequest, - decision: resolved, + payload: { answers: resolved }, + }); + return { answers: resolved }; + }), + ); + yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/create_plan", + params, + "acp.cursor.extension", + ); + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + payload: { planMarkdown: extractPlanMarkdown(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/create_plan", + payload: params, + }, + }); + return { accepted: true } as const; + }), + ); + yield* acp.handleExtNotification( + "cursor/update_todos", + CursorUpdateTodosRequest, + (params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "cursor/update_todos", + params, + "acp.cursor.extension", + ); + if (ctx) { + yield* emitPlanUpdate( + ctx, + extractTodosAsPlan(params), + params, + "acp.cursor.extension", + "cursor/update_todos", + ); + } }), - ); - return { - outcome: - resolved === "cancel" - ? ({ outcome: "cancelled" } as const) - : { + ); + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { outcome: "selected" as const, - optionId: acpPermissionOutcome(resolved), + optionId: autoApprovedOptionId, }, - }; - }), + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000), + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), ); - return yield* acp.start(); - }).pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), - ), - ); - - const now = yield* nowIso; - const session: ProviderSession = { - provider: PROVIDER, - status: "ready", - runtimeMode: input.runtimeMode, - cwd, - model: cursorModelSelection?.model, - threadId: input.threadId, - resumeCursor: { - schemaVersion: CURSOR_RESUME_VERSION, - sessionId: started.sessionId, - }, - createdAt: now, - updatedAt: now, - }; - - ctx = { - threadId: input.threadId, - session, - scope: sessionScope, - acp, - notificationFiber: undefined, - pendingApprovals, - pendingUserInputs, - turns: [], - lastPlanFingerprint: undefined, - activeTurnId: undefined, - stopped: false, - }; - const nf = yield* Stream.runDrain( - Stream.mapEffect(acp.events, (event) => - Effect.gen(function* () { - switch (event._tag) { - case "ModeChanged": - return; - case "AssistantItemStarted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.started", - }), - ); - return; - case "AssistantItemCompleted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.completed", - }), - ); - return; - case "PlanUpdated": - yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); - yield* emitPlanUpdate( - ctx, - event.payload, - event.rawPayload, - "acp.jsonrpc", - "session/update", - ); - return; - case "ToolCallUpdated": - yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); - yield* offerRuntimeEvent( - makeAcpToolCallEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - toolCall: event.toolCall, - rawPayload: event.rawPayload, - }), - ); - return; - case "ContentDelta": - yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); - yield* offerRuntimeEvent( - makeAcpContentDeltaEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - ...(event.itemId ? { itemId: event.itemId } : {}), - text: event.text, - rawPayload: event.rawPayload, - }), - ); - return; - } - }), - ), - ).pipe(Effect.forkChild); + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: cursorModelSelection?.model, + threadId: input.threadId, + resumeCursor: { + schemaVersion: CURSOR_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.events, (event) => + Effect.gen(function* () { + switch (event._tag) { + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe(Effect.forkChild); - ctx.notificationFiber = nf; - sessions.set(input.threadId, ctx); - sessionScopeTransferred = true; + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; - yield* offerRuntimeEvent({ - type: "session.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { resume: started.initializeResult }, - }); - yield* offerRuntimeEvent({ - type: "session.state.changed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { state: "ready", reason: "Cursor ACP session ready" }, - }); - yield* offerRuntimeEvent({ - type: "thread.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { providerThreadId: started.sessionId }, - }); + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Cursor ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); - return session; - }).pipe(Effect.scoped); + return session; + }).pipe(Effect.scoped), + ); const sendTurn: CursorAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { @@ -905,10 +953,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); const stopSession: CursorAdapterShape["stopSession"] = (threadId) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - yield* stopSessionInternal(ctx); - }); + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); const listSessions: CursorAdapterShape["listSessions"] = () => Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index fcfe5ad846b..d74253b4986 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -337,8 +337,6 @@ describe("AcpSessionRuntime", () => { message.params?.value === "composer-2[fast=false]", ), ).toBe(false); - - rmSync(tempDir, { recursive: true, force: true }); }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -356,6 +354,7 @@ describe("AcpSessionRuntime", () => { ), Effect.scoped, Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(tempDir, { recursive: true, force: true }))), ); }); }); From ff0e88abdaf2e1f54759e099da748a175da53aa7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 16:22:52 -0700 Subject: [PATCH 62/82] Start provider probes on startup - warm provider snapshots in background during registry init - avoid showing stdout as Cursor command detail when input is missing --- .../src/provider/Layers/CursorProvider.ts | 19 ++-- .../provider/Layers/ProviderRegistry.test.ts | 31 ++++-- .../src/provider/Layers/ProviderRegistry.ts | 3 + .../makeManagedServerProvider.test.ts | 102 ++++++++++-------- .../src/provider/makeManagedServerProvider.ts | 5 + apps/web/src/session-logic.test.ts | 35 ++++++ apps/web/src/session-logic.ts | 16 +++ 7 files changed, 150 insertions(+), 61 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index f966ba2be82..b96ffae2ed9 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -450,12 +450,9 @@ export function resolveCursorAcpConfigUpdates( export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => withCursorAcpProbeRuntime(cursorSettings, (acp) => - Effect.gen(function* () { - const started = yield* acp.start(); - return buildCursorDiscoveredModelsFromConfigOptions( - started.sessionSetupResult.configOptions ?? [], - ); - }), + Effect.map(acp.start(), (started) => + buildCursorDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions ?? []), + ), ); export const discoverCursorModelCapabilitiesViaAcp = ( @@ -516,6 +513,11 @@ export const discoverCursorModelCapabilitiesViaAcp = ( probeModelOption?.type === "select" ? probeModelOption.currentValue?.trim() || undefined : undefined; + yield* Effect.annotateCurrentSpan({ + "cursor.acp.model.value": modelSlug, + "cursor.acp.model.currentValue": probeCurrentModelValue, + "cursor.acp.config_option_id": probeModelOption?.id, + }); const nextConfigOptions = probeCurrentModelValue === modelSlug ? probeConfigOptions @@ -529,11 +531,12 @@ export const discoverCursorModelCapabilitiesViaAcp = ( }), ).pipe( Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), + Effect.withSpan("cursor-acp-model-capability-probe"), Effect.catchCause((cause) => Effect.logWarning("Cursor ACP capability probe failed", { modelSlug, cause: Cause.pretty(cause), - }).pipe(Effect.as(undefined)), + }), ), ); }, @@ -554,7 +557,7 @@ export const discoverCursorModelCapabilitiesViaAcp = ( capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, })), ); - }), + }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})), ); export function getCursorFallbackModels( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 79399df7ecb..b79e44779f4 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -654,10 +654,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ]); }); - it.effect("does not probe provider health during registry startup", () => + it.effect("probes enabled providers in the background during registry startup", () => Effect.gen(function* () { let spawnCount = 0; - const serverSettings = yield* makeMutableServerSettingsService(); + const serverSettings = yield* makeMutableServerSettingsService( + Schema.decodeSync(ServerSettings)( + deepMerge(DEFAULT_SERVER_SETTINGS, { + providers: { + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + }, + }), + ), + ); const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( @@ -696,11 +705,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, []); - assert.strictEqual(spawnCount, 0); - - const refreshed = yield* registry.refresh("codex"); assert.strictEqual(spawnCount > 0, true); + const refreshed = yield* Effect.gen(function* () { + for (let remainingAttempts = 50; remainingAttempts > 0; remainingAttempts -= 1) { + const providers = yield* registry.getProviders; + const codexProvider = providers.find((provider) => provider.provider === "codex"); + if (codexProvider?.status === "ready") { + return providers; + } + yield* Effect.sleep("10 millis"); + } + return yield* registry.getProviders; + }); assert.strictEqual( refreshed.find((provider) => provider.provider === "codex")?.status, "ready", @@ -760,9 +776,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; - const initial = yield* registry.getProviders; - assert.deepStrictEqual(initial, []); - const refreshed = yield* registry.refresh("codex"); assert.strictEqual( refreshed.find((status) => status.provider === "codex")?.status, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 009680149ae..03c96ef88af 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -239,6 +239,9 @@ export const ProviderRegistryLive = Layer.effect( yield* Stream.runForEach(cursorProvider.streamChanges, (provider) => syncProvider(provider), ).pipe(Effect.forkScoped); + yield* loadProviders(codexProvider, claudeProvider, cursorProvider).pipe( + Effect.flatMap((providers) => upsertProviders(providers, { publish: false })), + ); return { getProviders: Ref.get(providersRef), diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 27356f0b5c4..5aeabdd1d61 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -80,43 +80,44 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { - it.effect("keeps the initial snapshot until an explicit refresh runs", () => - Effect.scoped( - Effect.gen(function* () { - const checkCalls = yield* Ref.make(0); - const provider = yield* makeManagedServerProvider({ - getSettings: Effect.succeed({ enabled: true }), - streamSettings: Stream.empty, - haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, - initialSnapshot: () => initialSnapshot, - checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( - Effect.as(refreshedSnapshot), - ), - refreshInterval: "1 hour", - }); + it.effect( + "runs the initial provider check in the background and streams the refreshed snapshot", + () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const releaseCheck = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => initialSnapshot, + checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.flatMap(() => Deferred.await(releaseCheck)), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 hour", + }); - const initial = yield* provider.getSnapshot; - const beforeRefresh = yield* provider.getSnapshot; - assert.deepStrictEqual(initial, initialSnapshot); - assert.deepStrictEqual(beforeRefresh, initialSnapshot); - assert.strictEqual(yield* Ref.get(checkCalls), 0); + const initial = yield* provider.getSnapshot; + assert.deepStrictEqual(initial, initialSnapshot); - const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( - Stream.runCollect, - Effect.forkChild, - ); - yield* Effect.yieldNow; + const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; - const refreshed = yield* provider.refresh; - const updates = Array.from(yield* Fiber.join(updatesFiber)); - const latest = yield* provider.getSnapshot; + yield* Deferred.succeed(releaseCheck, undefined); - assert.deepStrictEqual(refreshed, refreshedSnapshot); - assert.deepStrictEqual(updates, [refreshedSnapshot]); - assert.deepStrictEqual(latest, refreshedSnapshot); - assert.strictEqual(yield* Ref.get(checkCalls), 1); - }), - ), + const updates = Array.from(yield* Fiber.join(updatesFiber)); + const latest = yield* provider.getSnapshot; + + assert.deepStrictEqual(updates, [refreshedSnapshot]); + assert.deepStrictEqual(latest, refreshedSnapshot); + assert.strictEqual(yield* Ref.get(checkCalls), 1); + }), + ), ); it.effect("reruns the provider check when streamed settings change", () => @@ -125,32 +126,40 @@ describe("makeManagedServerProvider", () => { const settingsRef = yield* Ref.make({ enabled: true }); const settingsChanges = yield* PubSub.unbounded(); const checkCalls = yield* Ref.make(0); + const releaseInitialCheck = yield* Deferred.make(); + const releaseSettingsCheck = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ getSettings: Ref.get(settingsRef), streamSettings: Stream.fromPubSub(settingsChanges), haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => initialSnapshot, - checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( - Effect.as(refreshedSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Deferred.await(releaseInitialCheck).pipe(Effect.as(refreshedSnapshot)) + : Deferred.await(releaseSettingsCheck).pipe(Effect.as(refreshedSnapshotSecond)), + ), ), refreshInterval: "1 hour", }); - const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( + const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe( Stream.runCollect, Effect.forkChild, ); yield* Effect.yieldNow; + yield* Deferred.succeed(releaseInitialCheck, undefined); yield* Ref.set(settingsRef, { enabled: false }); yield* PubSub.publish(settingsChanges, { enabled: false }); + yield* Deferred.succeed(releaseSettingsCheck, undefined); const updates = Array.from(yield* Fiber.join(updatesFiber)); const latest = yield* provider.getSnapshot; - assert.deepStrictEqual(updates, [refreshedSnapshot]); - assert.deepStrictEqual(latest, refreshedSnapshot); - assert.strictEqual(yield* Ref.get(checkCalls), 1); + assert.deepStrictEqual(updates, [refreshedSnapshot, refreshedSnapshotSecond]); + assert.deepStrictEqual(latest, refreshedSnapshotSecond); + assert.strictEqual(yield* Ref.get(checkCalls), 2); }), ), ); @@ -159,12 +168,13 @@ describe("makeManagedServerProvider", () => { Effect.scoped( Effect.gen(function* () { const releaseEnrichment = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ getSettings: Effect.succeed({ enabled: true }), streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => initialSnapshot, - checkProvider: Effect.succeed(refreshedSnapshot), + checkProvider: Deferred.await(releaseCheck).pipe(Effect.as(refreshedSnapshot)), enrichSnapshot: ({ publishSnapshot }) => Deferred.await(releaseEnrichment).pipe( Effect.flatMap(() => publishSnapshot(enrichedSnapshot)), @@ -178,8 +188,7 @@ describe("makeManagedServerProvider", () => { ); yield* Effect.yieldNow; - const refreshed = yield* provider.refresh; - assert.deepStrictEqual(refreshed, refreshedSnapshot); + yield* Deferred.succeed(releaseCheck, undefined); yield* Deferred.succeed(releaseEnrichment, undefined); @@ -199,13 +208,18 @@ describe("makeManagedServerProvider", () => { const refreshCount = yield* Ref.make(0); const firstCallbackReady = yield* Deferred.make(); const secondCallbackReady = yield* Deferred.make(); + const allowFirstRefresh = yield* Deferred.make(); const provider = yield* makeManagedServerProvider({ getSettings: Effect.succeed({ enabled: true }), streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => initialSnapshot, checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( - Effect.map((count) => (count === 1 ? refreshedSnapshot : refreshedSnapshotSecond)), + Effect.flatMap((count) => + count === 1 + ? Deferred.await(allowFirstRefresh).pipe(Effect.as(refreshedSnapshot)) + : Effect.succeed(refreshedSnapshotSecond), + ), ), enrichSnapshot: ({ publishSnapshot }) => Effect.gen(function* () { @@ -225,7 +239,7 @@ describe("makeManagedServerProvider", () => { ); yield* Effect.yieldNow; - yield* provider.refresh; + yield* Deferred.succeed(allowFirstRefresh, undefined); yield* Deferred.await(firstCallbackReady); yield* provider.refresh; diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 7dc2cda2093..a1adc3439bc 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -137,6 +137,11 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( ), ).pipe(Effect.forkScoped); + yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + ); + return { getSnapshot: input.getSettings.pipe( Effect.flatMap(applySnapshot), diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 10eb19926cd..6f9bd60d458 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1038,6 +1038,41 @@ describe("deriveWorkLogEntries", () => { }); }); + it("does not use command stdout as the detail when Cursor omits the command input", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "cursor-command-complete", + createdAt: "2026-04-16T22:40:42.221Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "toolu_vrtx_01WypXgRM8PPygBtrVAZwzy5", + kind: "execute", + rawInput: {}, + rawOutput: { + exitCode: 0, + stdout: "total 960\napps\npackages\n", + stderr: "", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry).toMatchObject({ + id: "cursor-command-complete", + label: "Ran command", + itemType: "command_execution", + toolTitle: "Ran command", + }); + expect(entry?.detail).toBeUndefined(); + expect(entry?.command).toBeUndefined(); + }); + it("collapses legacy completed tool rows that are missing tool metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 9a6e9b0ff19..062106daae1 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -944,6 +944,18 @@ function summarizeToolRawOutput(payload: Record | null): string return null; } +function isCommandToolDetail(payload: Record | null, heading: string): boolean { + const data = asRecord(payload?.data); + const kind = asTrimmedString(data?.kind)?.toLowerCase(); + const title = asTrimmedString(payload?.title ?? heading)?.toLowerCase(); + return ( + extractWorkLogItemType(payload) === "command_execution" || + kind === "execute" || + title === "terminal" || + title === "ran command" + ); +} + function extractToolDetail( payload: Record | null, heading: string, @@ -957,6 +969,10 @@ function extractToolDetail( return detail; } + if (isCommandToolDetail(payload, heading)) { + return null; + } + const rawOutputSummary = summarizeToolRawOutput(payload); if (rawOutputSummary) { const normalizedRawOutputSummary = normalizePreviewForComparison(rawOutputSummary); From 188f902fb41e49dede1e9646d1b2858c98552351 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 17:41:28 -0700 Subject: [PATCH 63/82] Gate Cursor provider behind runtime flag - Add `T3CODE_CURSOR_ENABLED` check on the server - Hide Cursor in provider picker and settings when unavailable - Update provider enablement logic for empty server state --- apps/server/src/cursorFeatureFlag.ts | 10 ++ .../provider/Layers/ProviderRegistry.test.ts | 66 ++++++++ .../src/provider/Layers/ProviderRegistry.ts | 142 +++++++++++------- apps/web/src/components/chat/ChatComposer.tsx | 30 ++-- .../chat/ProviderModelPicker.browser.tsx | 20 +++ .../components/chat/ProviderModelPicker.tsx | 29 +++- .../components/settings/SettingsPanels.tsx | 7 +- apps/web/src/providerModels.ts | 5 +- packages/contracts/src/orchestration.ts | 4 - 9 files changed, 230 insertions(+), 83 deletions(-) create mode 100644 apps/server/src/cursorFeatureFlag.ts diff --git a/apps/server/src/cursorFeatureFlag.ts b/apps/server/src/cursorFeatureFlag.ts new file mode 100644 index 00000000000..efa6009ae9c --- /dev/null +++ b/apps/server/src/cursorFeatureFlag.ts @@ -0,0 +1,10 @@ +export const isTruthyEnvFlag = (value: string | undefined): boolean => { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return false; + } + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +}; + +export const isCursorEnabled = (value = process.env.T3CODE_CURSOR_ENABLED): boolean => + isTruthyEnvFlag(value); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b79e44779f4..241390dab22 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -39,6 +39,8 @@ import { ServerConfig } from "../../config"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; import { ProviderRegistry } from "../Services/ProviderRegistry"; +process.env.T3CODE_CURSOR_ENABLED = "1"; + // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); @@ -807,6 +809,70 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ); + it.effect("omits cursor entirely when the runtime flag is disabled", () => + Effect.gen(function* () { + const previous = process.env.T3CODE_CURSOR_ENABLED; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) { + delete process.env.T3CODE_CURSOR_ENABLED; + } else { + process.env.T3CODE_CURSOR_ENABLED = previous; + } + }), + ); + delete process.env.T3CODE_CURSOR_ENABLED; + + let cursorSpawned = false; + const serverSettings = yield* makeMutableServerSettingsService(); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + if (command === "agent") { + cursorSpawned = true; + } + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: `${command} 1.0.0\n`, stderr: "", code: 0 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + if (joined === "auth status") { + return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${command} ${joined}`); + }), + ), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const providers = yield* registry.getProviders; + + assert.deepStrictEqual( + providers.map((provider) => provider.provider), + ["codex", "claudeAgent"], + ); + assert.strictEqual(cursorSpawned, false); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("skips codex probes entirely when the provider is disabled", () => Effect.gen(function* () { const serverSettingsLayer = ServerSettingsService.layerTest({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 03c96ef88af..dd78f8e3860 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -7,14 +7,12 @@ import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; import { ServerConfig } from "../../config"; +import { isCursorEnabled } from "../../cursorFeatureFlag"; import { ClaudeProviderLive } from "./ClaudeProvider"; import { CodexProviderLive } from "./CodexProvider"; import { CursorProviderLive } from "./CursorProvider"; -import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; -import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; -import type { CursorProviderShape } from "../Services/CursorProvider"; import { CursorProvider } from "../Services/CursorProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; import { @@ -26,12 +24,17 @@ import { writeProviderStatusCache, } from "../providerStatusCache"; +type ProviderSnapshotSource = { + readonly provider: ProviderKind; + readonly getSnapshot: Effect.Effect; + readonly refresh: Effect.Effect; + readonly streamChanges: Stream.Stream; +}; + const loadProviders = ( - codexProvider: CodexProviderShape, - claudeProvider: ClaudeProviderShape, - cursorProvider: CursorProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot, cursorProvider.getSnapshot], { + providerSources: ReadonlyArray, +): Effect.Effect> => + Effect.forEach(providerSources, (providerSource) => providerSource.getSnapshot, { concurrency: "unbounded", }); @@ -81,22 +84,51 @@ export const haveProvidersChanged = ( nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); -export const ProviderRegistryLive = Layer.effect( +const ProviderRegistryLiveBase = Layer.effect( ProviderRegistry, Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; - const cursorProvider = yield* CursorProvider; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const cursorEnabled = isCursorEnabled(); + const cursorProviderOption = yield* Effect.serviceOption(CursorProvider); + const cursorProvider = + cursorEnabled && cursorProviderOption._tag === "Some" ? cursorProviderOption.value : null; + const cursorProviderSource = cursorProvider + ? ({ + provider: "cursor", + getSnapshot: cursorProvider.getSnapshot, + refresh: cursorProvider.refresh, + streamChanges: cursorProvider.streamChanges, + } satisfies ProviderSnapshotSource) + : null; + const providerSources = [ + { + provider: "codex", + getSnapshot: codexProvider.getSnapshot, + refresh: codexProvider.refresh, + streamChanges: codexProvider.streamChanges, + }, + { + provider: "claudeAgent", + getSnapshot: claudeProvider.getSnapshot, + refresh: claudeProvider.refresh, + streamChanges: claudeProvider.streamChanges, + }, + ...(cursorProviderSource ? [cursorProviderSource] : []), + ] satisfies ReadonlyArray; + const activeProviders = PROVIDER_CACHE_IDS.filter( + (provider) => provider !== "cursor" || cursorEnabled, + ); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); - const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider, cursorProvider); + const fallbackProviders = yield* loadProviders(providerSources); const cachePathByProvider = new Map( - PROVIDER_CACHE_IDS.map( + activeProviders.map( (provider) => [ provider, @@ -110,8 +142,9 @@ export const ProviderRegistryLive = Layer.effect( const fallbackByProvider = new Map( fallbackProviders.map((provider) => [provider.provider, provider] as const), ); + const cachedProviders = yield* Effect.forEach( - PROVIDER_CACHE_IDS, + activeProviders, (provider) => { const filePath = cachePathByProvider.get(provider)!; const fallbackProvider = fallbackByProvider.get(provider)!; @@ -196,50 +229,38 @@ export const ProviderRegistryLive = Layer.effect( }); const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { - switch (provider) { - case "codex": - return yield* codexProvider.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ); - case "claudeAgent": - return yield* claudeProvider.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ); - case "cursor": - return yield* cursorProvider.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ); - default: - return yield* Effect.all( - [ - codexProvider.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ), - claudeProvider.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ), - cursorProvider.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ), - ], - { - concurrency: "unbounded", - discard: true, - }, - ).pipe(Effect.andThen(Ref.get(providersRef))); + if (provider) { + const providerSource = providerSources.find((candidate) => candidate.provider === provider); + if (!providerSource) { + return yield* Ref.get(providersRef); + } + return yield* providerSource.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ); } + + return yield* Effect.forEach( + providerSources, + (providerSource) => providerSource.refresh.pipe(Effect.flatMap(syncProvider)), + { + concurrency: "unbounded", + discard: true, + }, + ).pipe(Effect.andThen(Ref.get(providersRef))); }); - yield* Stream.runForEach(codexProvider.streamChanges, (provider) => - syncProvider(provider), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(claudeProvider.streamChanges, (provider) => - syncProvider(provider), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(cursorProvider.streamChanges, (provider) => - syncProvider(provider), - ).pipe(Effect.forkScoped); - yield* loadProviders(codexProvider, claudeProvider, cursorProvider).pipe( + yield* Effect.forEach( + providerSources, + (providerSource) => + Stream.runForEach(providerSource.streamChanges, (provider) => syncProvider(provider)).pipe( + Effect.forkScoped, + ), + { + concurrency: "unbounded", + discard: true, + }, + ); + yield* loadProviders(providerSources).pipe( Effect.flatMap((providers) => upsertProviders(providers, { publish: false })), ); @@ -255,8 +276,13 @@ export const ProviderRegistryLive = Layer.effect( }, } satisfies ProviderRegistryShape; }), -).pipe( - Layer.provideMerge(CodexProviderLive), - Layer.provideMerge(ClaudeProviderLive), - Layer.provideMerge(CursorProviderLive), +); + +export const ProviderRegistryLive = Layer.unwrap( + Effect.sync(() => + (isCursorEnabled() + ? ProviderRegistryLiveBase.pipe(Layer.provideMerge(CursorProviderLive)) + : ProviderRegistryLiveBase + ).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)), + ), ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d6b6586a18e..8465937e8f0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -59,7 +59,7 @@ import { shouldUseCompactComposerFooter, } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; -import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./ProviderModelPicker"; +import { getAvailableProviderOptions, ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -616,20 +616,20 @@ export const ChatComposer = memo( }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, - ).flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), - })), - ), - [lockedProvider, modelOptionsByProvider], + getAvailableProviderOptions(providerStatuses) + .filter((option) => lockedProvider === null || option.value === lockedProvider) + .flatMap((option) => + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ + provider: option.value, + providerLabel: option.label, + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + searchProvider: option.label.toLowerCase(), + })), + ), + [lockedProvider, modelOptionsByProvider, providerStatuses], ); // ------------------------------------------------------------------ diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index abedcd6eeb3..2860a872626 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -420,4 +420,24 @@ describe("ProviderModelPicker", () => { await mounted.cleanup(); } }); + + it("hides cursor when the server does not expose it", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + providers: TEST_PROVIDERS, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).not.toContain("Cursor"); + }); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 612aa24d92c..b1900aea312 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -50,6 +50,25 @@ function providerIconClassName( return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; } +function isHiddenProviderOption( + option: (typeof PROVIDER_OPTIONS)[number], + providers: ReadonlyArray | undefined, +): boolean { + return ( + option.value === "cursor" && + (providers?.length ?? 0) > 0 && + getProviderSnapshot(providers ?? [], option.value) === undefined + ); +} + +export function getAvailableProviderOptions(providers?: ReadonlyArray) { + return AVAILABLE_PROVIDER_OPTIONS.filter((option) => !isHiddenProviderOption(option, providers)); +} + +export function getUnavailableProviderOptions() { + return UNAVAILABLE_PROVIDER_OPTIONS; +} + export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: string; @@ -66,6 +85,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; + const availableProviderOptions = getAvailableProviderOptions(props.providers); + const unavailableProviderOptions = getUnavailableProviderOptions(); const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelValue = resolveSelectableModel(activeProvider, props.model, selectedProviderOptions) ?? props.model; @@ -149,7 +170,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
) : ( <> - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + {availableProviderOptions.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; const liveProvider = props.providers ? getProviderSnapshot(props.providers, option.value) @@ -217,8 +238,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ); })} - {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } - {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { + {unavailableProviderOptions.length > 0 && } + {unavailableProviderOptions.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; return ( @@ -233,7 +254,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ); })} - {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } + {unavailableProviderOptions.length === 0 && } {COMING_SOON_PROVIDER_OPTIONS.map((option) => { const OptionIcon = option.icon; return ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5f1c934b860..c972bbcdb88 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -558,6 +558,11 @@ export function GeneralSettingsPanel() { const availableEditors = useServerAvailableEditors(); const observability = useServerObservability(); const serverProviders = useServerProviders(); + const visibleProviderSettings = PROVIDER_SETTINGS.filter( + (providerSettings) => + providerSettings.provider !== "cursor" || + serverProviders.some((provider) => provider.provider === "cursor"), + ); const codexHomePath = settings.providers.codex.homePath; const logsDirectoryPath = observability?.logsDirectoryPath ?? null; const diagnosticsDescription = (() => { @@ -722,7 +727,7 @@ export function GeneralSettingsPanel() { [settings, updateSettings], ); - const providerCards = PROVIDER_SETTINGS.map((providerSettings) => { + const providerCards = visibleProviderSettings.map((providerSettings) => { const liveProvider = serverProviders.find( (candidate) => candidate.provider === providerSettings.provider, ); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index f4032e533d4..e901a895f49 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -39,7 +39,10 @@ export function isProviderEnabled( providers: ReadonlyArray, provider: ProviderKind, ): boolean { - return getProviderSnapshot(providers, provider)?.enabled ?? true; + if (providers.length === 0) { + return true; + } + return getProviderSnapshot(providers, provider)?.enabled ?? false; } export function resolveSelectableProvider( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 957a0f3cdf3..fb7249842ec 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -25,10 +25,6 @@ export const ORCHESTRATION_WS_METHODS = { subscribeThread: "orchestration.subscribeThread", } as const; -export const ORCHESTRATION_WS_CHANNELS = { - domainEvent: "orchestration.domainEvent", -} as const; - export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ From 8e18135091260181b948ad390f550cd424cc1870 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 21:25:09 -0700 Subject: [PATCH 64/82] Always enable Cursor flag - Force Cursor support on regardless of env flag - Preserve the existing env helper for future use --- apps/server/src/cursorFeatureFlag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/cursorFeatureFlag.ts b/apps/server/src/cursorFeatureFlag.ts index efa6009ae9c..ede437b0271 100644 --- a/apps/server/src/cursorFeatureFlag.ts +++ b/apps/server/src/cursorFeatureFlag.ts @@ -7,4 +7,4 @@ export const isTruthyEnvFlag = (value: string | undefined): boolean => { }; export const isCursorEnabled = (value = process.env.T3CODE_CURSOR_ENABLED): boolean => - isTruthyEnvFlag(value); + true || isTruthyEnvFlag(value); From 75cff58008805b5d6a607e4dcf97089cc1e60b8e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 04:52:40 +0000 Subject: [PATCH 65/82] fix: remove hardcoded true from isCursorEnabled feature flag The expression 'true || isTruthyEnvFlag(value)' always short-circuits to true, making the T3CODE_CURSOR_ENABLED env var check dead code. This was likely a developer override left in for local testing. --- apps/server/src/cursorFeatureFlag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/cursorFeatureFlag.ts b/apps/server/src/cursorFeatureFlag.ts index ede437b0271..efa6009ae9c 100644 --- a/apps/server/src/cursorFeatureFlag.ts +++ b/apps/server/src/cursorFeatureFlag.ts @@ -7,4 +7,4 @@ export const isTruthyEnvFlag = (value: string | undefined): boolean => { }; export const isCursorEnabled = (value = process.env.T3CODE_CURSOR_ENABLED): boolean => - true || isTruthyEnvFlag(value); + isTruthyEnvFlag(value); From c9207c7b6ee47b4ce2435e1babc4df505b076992 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 14:13:01 -0700 Subject: [PATCH 66/82] Remove cursor feature-flag gating - Always provision the cursor provider in server startup - Simplify live test wrappers and related assertions --- apps/desktop/src/main.ts | 1 - .../orchestrationEngine.integration.test.ts | 756 +++++++++--------- apps/server/src/cursorFeatureFlag.ts | 10 - apps/server/src/git/Layers/GitCore.test.ts | 256 +++--- apps/server/src/git/Layers/GitManager.test.ts | 341 ++++---- .../Layers/CheckpointReactor.test.ts | 31 +- .../Layers/ProviderCommandReactor.test.ts | 306 +------ .../src/provider/Layers/ProviderRegistry.ts | 33 +- apps/server/src/server.ts | 7 +- 9 files changed, 696 insertions(+), 1045 deletions(-) delete mode 100644 apps/server/src/cursorFeatureFlag.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4aa299ad4fb..529ed55d03f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -445,7 +445,6 @@ async function waitForBackendHttpReady( try { await waitForHttpReady(baseUrl, { ...options, - path: options?.path ?? "/.well-known/t3/environment", signal: controller.signal, }); } finally { diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 6bd1dc532e1..a7f845672ca 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -39,12 +39,6 @@ const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.make("thread-1"); const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); -const LONG_LIVE_TEST_TIMEOUT_MS = 60_000; -const live = ( - name: string, - test: Parameters[1], - timeout = LONG_LIVE_TEST_TIMEOUT_MS, -) => it.live(name, test, timeout); type IntegrationProvider = ProviderKind; function nowIso() { @@ -173,7 +167,7 @@ const startTurn = (input: { createdAt: nowIso(), }); -live("runs a single turn end-to-end and persists checkpoint state in sqlite + git", () => +it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + git", () => withHarness((harness) => Effect.gen(function* () { yield* seedProjectAndThread(harness); @@ -352,7 +346,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( ), ); -live("runs multi-turn file edits and persists checkpoint diffs", () => +it.live("runs multi-turn file edits and persists checkpoint diffs", () => withHarness((harness) => Effect.gen(function* () { yield* seedProjectAndThread(harness); @@ -533,7 +527,7 @@ live("runs multi-turn file edits and persists checkpoint diffs", () => ), ); -live("tracks approval requests and resolves pending approvals on user response", () => +it.live("tracks approval requests and resolves pending approvals on user response", () => withHarness((harness) => Effect.gen(function* () { yield* seedProjectAndThread(harness); @@ -614,273 +608,264 @@ live("tracks approval requests and resolves pending approvals on user response", ), ); -live( - "records failed turn runtime state and checkpoint status as error", - () => - withHarness((harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); +it.live("records failed turn runtime state and checkpoint status as error", () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "content.delta", - ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - payload: { - streamKind: "assistant_text", - delta: "Partial output before failure.\n", - }, + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "content.delta", + ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + payload: { + streamKind: "assistant_text", + delta: "Partial output before failure.\n", }, - { - type: "runtime.error", - ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - payload: { - message: "Sandbox command failed.", - }, + }, + { + type: "runtime.error", + ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + payload: { + message: "Sandbox command failed.", }, - { - type: "turn.completed", - ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - payload: { - state: "failed", - errorMessage: "Sandbox command failed.", - }, + }, + { + type: "turn.completed", + ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + payload: { + state: "failed", + errorMessage: "Sandbox command failed.", }, - ], - }); + }, + ], + }); - yield* startTurn({ - harness, - commandId: "cmd-turn-start-failure", - messageId: "msg-user-failure", - text: "Run risky command", - }); + yield* startTurn({ + harness, + commandId: "cmd-turn-start-failure", + messageId: "msg-user-failure", + text: "Run risky command", + }); - const thread = yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.session?.status === "error" && - entry.session?.lastError === "Sandbox command failed." && - entry.activities.some((activity) => activity.kind === "runtime.error") && - entry.checkpoints.length === 1, - 30_000, - ); - assert.equal(thread.session?.status, "error"); - assert.equal(thread.checkpoints[0]?.status, "error"); + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "error" && + entry.session?.lastError === "Sandbox command failed." && + entry.activities.some((activity) => activity.kind === "runtime.error") && + entry.checkpoints.length === 1, + ); + assert.equal(thread.session?.status, "error"); + assert.equal(thread.checkpoints[0]?.status, "error"); - const checkpointRow = yield* harness.checkpointRepository.getByThreadAndTurnCount({ - threadId: THREAD_ID, - checkpointTurnCount: 1, - }); - assert.equal(Option.isSome(checkpointRow), true); - if (Option.isSome(checkpointRow)) { - assert.equal(checkpointRow.value.status, "error"); - } - assert.equal( - gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), - true, - ); - }), - ), - LONG_LIVE_TEST_TIMEOUT_MS, + const checkpointRow = yield* harness.checkpointRepository.getByThreadAndTurnCount({ + threadId: THREAD_ID, + checkpointTurnCount: 1, + }); + assert.equal(Option.isSome(checkpointRow), true); + if (Option.isSome(checkpointRow)) { + assert.equal(checkpointRow.value.status, "error"); + } + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), + true, + ); + }), + ), ); -live( - "reverts to an earlier checkpoint and trims checkpoint projections + git refs", - () => - withHarness((harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); +it.live("reverts to an earlier checkpoint and trims checkpoint projections + git refs", () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "tool.started", - ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", - }, - { - type: "tool.completed", - ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", - }, - { - type: "message.delta", - ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "Updated README to v2.\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - mutateWorkspace: ({ cwd }) => - Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); - }), - }); - yield* startTurn({ - harness, - commandId: "cmd-turn-start-revert-1", - messageId: "msg-user-revert-1", - text: "First edit", - }); + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "tool.started", + ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "tool.completed", + ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "message.delta", + ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Updated README to v2.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + }), + }); + yield* startTurn({ + harness, + commandId: "cmd-turn-start-revert-1", + messageId: "msg-user-revert-1", + text: "First edit", + }); - yield* harness.waitForThread( - THREAD_ID, - (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1, - 30_000, - ); + yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.session?.threadId === "thread-1" && entry.checkpoints.length === 1, + ); - yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { - events: [ - { - type: "turn.started", - ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "tool.started", - ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", - }, - { - type: "tool.completed", - ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - toolKind: "command", - title: "Edit file", - detail: "README.md", - }, - { - type: "message.delta", - ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "Updated README to v3.\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - mutateWorkspace: ({ cwd }) => - Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); - }), - }); - yield* startTurn({ - harness, - commandId: "cmd-turn-start-revert-2", - messageId: "msg-user-revert-2", - text: "Second edit", - }); + yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "tool.started", + ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "tool.completed", + ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + toolKind: "command", + title: "Edit file", + detail: "README.md", + }, + { + type: "message.delta", + ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Updated README to v3.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + }), + }); + yield* startTurn({ + harness, + commandId: "cmd-turn-start-revert-2", + messageId: "msg-user-revert-2", + text: "Second edit", + }); - yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.latestTurn?.turnId === "turn-2" && - entry.checkpoints.length === 2 && - entry.activities.some((activity) => activity.turnId === "turn-2"), - 8000, - ); + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.activities.some((activity) => activity.turnId === "turn-2"), + 8000, + ); - yield* harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.make("cmd-checkpoint-revert"), - threadId: THREAD_ID, - turnCount: 1, - createdAt: nowIso(), - }); + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-checkpoint-revert"), + threadId: THREAD_ID, + turnCount: 1, + createdAt: nowIso(), + }); - yield* harness.waitForDomainEvent((event) => event.type === "thread.reverted"); - const revertedThread = yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, - 30_000, - ); - assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); - assert.deepEqual( - revertedThread.messages.map((message) => ({ role: message.role, text: message.text })), - [ - { role: "user", text: "First edit" }, - { role: "assistant", text: "Updated README to v2.\n" }, - ], - ); - assert.equal( - revertedThread.activities.some((activity) => activity.turnId === "turn-2"), - false, - ); - assert.equal( - revertedThread.activities.some( - (activity) => activity.turnId === "turn-1" && activity.kind === "tool.started", - ), - true, - ); - assert.equal( - revertedThread.activities.some( - (activity) => activity.turnId === "turn-1" && activity.kind === "tool.completed", - ), - true, - ); - assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); - assert.equal( - gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), - false, - ); - assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); + yield* harness.waitForDomainEvent((event) => event.type === "thread.reverted"); + const revertedThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, + ); + assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); + assert.deepEqual( + revertedThread.messages.map((message) => ({ role: message.role, text: message.text })), + [ + { role: "user", text: "First edit" }, + { role: "assistant", text: "Updated README to v2.\n" }, + ], + ); + assert.equal( + revertedThread.activities.some((activity) => activity.turnId === "turn-2"), + false, + ); + assert.equal( + revertedThread.activities.some( + (activity) => activity.turnId === "turn-1" && activity.kind === "tool.started", + ), + true, + ); + assert.equal( + revertedThread.activities.some( + (activity) => activity.turnId === "turn-1" && activity.kind === "tool.completed", + ), + true, + ); + assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), + false, + ); + assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); - const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ - threadId: THREAD_ID, - }); - assert.equal(checkpointRows.length, 1); - }), - ), - LONG_LIVE_TEST_TIMEOUT_MS, + const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ + threadId: THREAD_ID, + }); + assert.equal(checkpointRows.length, 1); + }), + ), ); -live( +it.live( "appends checkpoint.revert.failed activity when revert is requested without an active session", () => withHarness((harness) => @@ -917,7 +902,7 @@ live( ), ); -live("starts a claudeAgent session on first turn when provider is requested", () => +it.live("starts a claudeAgent session on first turn when provider is requested", () => withHarness( (harness) => Effect.gen(function* () { @@ -974,7 +959,7 @@ live("starts a claudeAgent session on first turn when provider is requested", () ), ); -live("recovers claudeAgent sessions after provider stopAll using persisted resume state", () => +it.live("recovers claudeAgent sessions after provider stopAll using persisted resume state", () => withHarness( (harness) => Effect.gen(function* () { @@ -1020,7 +1005,6 @@ live("recovers claudeAgent sessions after provider stopAll using persisted resum THREAD_ID, (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", - 30_000, ); yield* harness.adapterHarness!.adapter.stopAll(); @@ -1083,7 +1067,7 @@ live("recovers claudeAgent sessions after provider stopAll using persisted resum ), ); -live("forwards claudeAgent approval responses to the provider session", () => +it.live("forwards claudeAgent approval responses to the provider session", () => withHarness( (harness) => Effect.gen(function* () { @@ -1157,7 +1141,7 @@ live("forwards claudeAgent approval responses to the provider session", () => ), ); -live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => +it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => withHarness( (harness) => Effect.gen(function* () { @@ -1226,130 +1210,126 @@ live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => ), ); -live( - "reverts claudeAgent turns and rolls back provider conversation state", - () => - withHarness( - (harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); - - yield* harness.adapterHarness!.queueTurnResponseForNextSession({ - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "README -> v2\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - mutateWorkspace: ({ cwd }) => - Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); - }), - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-revert-1", - messageId: "msg-user-claude-revert-1", - text: "First Claude edit", - modelSelection: { - provider: "claudeAgent", - model: "claude-sonnet-4-6", +it.live("reverts claudeAgent turns and rolls back provider conversation state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, }, - }); - - yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", - ); - - yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { - events: [ - { - type: "turn.started", - ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - }, - { - type: "message.delta", - ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - delta: "README -> v3\n", - }, - { - type: "turn.completed", - ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeAgent"), - threadId: THREAD_ID, - turnId: FIXTURE_TURN_ID, - status: "completed", - }, - ], - mutateWorkspace: ({ cwd }) => - Effect.sync(() => { - fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); - }), - }); - - yield* startTurn({ - harness, - commandId: "cmd-turn-start-claude-revert-2", - messageId: "msg-user-claude-revert-2", - text: "Second Claude edit", - }); - - yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.latestTurn?.turnId === "turn-2" && - entry.checkpoints.length === 2 && - entry.session?.providerName === "claudeAgent", - 30_000, - ); - - yield* harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: CommandId.make("cmd-checkpoint-revert-claude"), - threadId: THREAD_ID, - turnCount: 1, - createdAt: nowIso(), - }); - - const revertedThread = yield* harness.waitForThread( - THREAD_ID, - (entry) => - entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, - ); - assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); - assert.equal( - gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), - true, - ); - assert.equal( - gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), - false, - ); - assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); - }), - "claudeAgent", - ), - LONG_LIVE_TEST_TIMEOUT_MS, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v2\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-1", + messageId: "msg-user-claude-revert-1", + text: "First Claude edit", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", + ); + + yield* harness.adapterHarness!.queueTurnResponse(THREAD_ID, { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v3\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeAgent"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-2", + messageId: "msg-user-claude-revert-2", + text: "Second Claude edit", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.session?.providerName === "claudeAgent", + ); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-checkpoint-revert-claude"), + threadId: THREAD_ID, + turnCount: 1, + createdAt: nowIso(), + }); + + const revertedThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, + ); + assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), + true, + ); + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), + false, + ); + assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]); + }), + "claudeAgent", + ), ); diff --git a/apps/server/src/cursorFeatureFlag.ts b/apps/server/src/cursorFeatureFlag.ts deleted file mode 100644 index efa6009ae9c..00000000000 --- a/apps/server/src/cursorFeatureFlag.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const isTruthyEnvFlag = (value: string | undefined): boolean => { - const normalized = value?.trim().toLowerCase(); - if (!normalized) { - return false; - } - return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; -}; - -export const isCursorEnabled = (value = process.env.T3CODE_CURSOR_ENABLED): boolean => - isTruthyEnvFlag(value); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 52bae7a0b41..665c4b138f9 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -183,16 +183,8 @@ function splitNullSeparatedPaths(input: string): string[] { // ── Tests ── it.layer(TestLayer)("git integration", (it) => { - const LONG_EFFECT_TEST_TIMEOUT_MS = 30_000; - const ASYNC_GIT_WAIT_TIMEOUT_MS = 30_000; - const effect = ( - name: string, - test: Parameters[1], - timeout = LONG_EFFECT_TEST_TIMEOUT_MS, - ) => it.effect(name, test, timeout); - describe("shell process execution", () => { - effect("caps captured output when maxOutputBytes is exceeded", () => + it.effect("caps captured output when maxOutputBytes is exceeded", () => Effect.gen(function* () { const result = yield* runShellCommand({ command: `node -e "process.stdout.write('x'.repeat(2000))"`, @@ -211,7 +203,7 @@ it.layer(TestLayer)("git integration", (it) => { // ── initGitRepo ── describe("initGitRepo", () => { - effect("creates a valid git repo", () => + it.effect("creates a valid git repo", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* (yield* GitCore).initRepo({ cwd: tmp }); @@ -219,7 +211,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("listGitBranches reports isRepo: true after init + commit", () => + it.effect("listGitBranches reports isRepo: true after init + commit", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -315,7 +307,7 @@ it.layer(TestLayer)("git integration", (it) => { // ── listGitBranches ── describe("listGitBranches", () => { - effect("returns isRepo: false for non-git directory", () => + it.effect("returns isRepo: false for non-git directory", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); @@ -325,7 +317,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("returns isRepo: false for deleted directories", () => + it.effect("returns isRepo: false for deleted directories", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const deletedDir = path.join(tmp, "deleted-repo"); @@ -340,7 +332,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("returns the current branch with current: true", () => + it.effect("returns the current branch with current: true", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -351,7 +343,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("does not include detached HEAD pseudo-refs as branches", () => + it.effect("does not include detached HEAD pseudo-refs as branches", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -363,7 +355,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("keeps current branch first and sorts the remaining branches by recency", () => + it.effect("keeps current branch first and sorts the remaining branches by recency", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -401,7 +393,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("keeps default branch right after current branch", () => + it.effect("keeps default branch right after current branch", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const remote = yield* makeTmpDir(); @@ -445,7 +437,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("lists multiple branches after creating them", () => + it.effect("lists multiple branches after creating them", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -556,7 +548,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("lists local branches first and remote branches last", () => + it.effect("lists local branches first and remote branches last", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const tmp = yield* makeTmpDir(); @@ -603,7 +595,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("includes remoteName metadata for remotes with slash in the name", () => + it.effect("includes remoteName metadata for remotes with slash in the name", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const tmp = yield* makeTmpDir(); @@ -674,7 +666,7 @@ it.layer(TestLayer)("git integration", (it) => { // ── checkoutGitBranch ── describe("checkoutGitBranch", () => { - effect("checks out an existing branch", () => + it.effect("checks out an existing branch", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -688,7 +680,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("refreshes upstream behind count after checkout when remote branch advanced", () => + it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); @@ -734,7 +726,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(details.behindCount).toBe(1); }, { - timeout: ASYNC_GIT_WAIT_TIMEOUT_MS, + timeout: 10_000, interval: 100, }, ), @@ -742,7 +734,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("statusDetails remains successful when upstream refresh fails after checkout", () => + it.effect("statusDetails remains successful when upstream refresh fails after checkout", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -789,7 +781,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("defers upstream refresh until statusDetails is requested", () => + it.effect("defers upstream refresh until statusDetails is requested", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -834,7 +826,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("coalesces upstream refreshes across sibling worktrees on the same remote", () => + it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () => Effect.gen(function* () { const ok = (stdout = "") => Effect.succeed({ @@ -908,7 +900,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect( + it.effect( "briefly backs off failed upstream refreshes across sibling worktrees on one remote", () => Effect.gen(function* () { @@ -984,7 +976,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("throws when branch does not exist", () => + it.effect("throws when branch does not exist", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -995,7 +987,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("does not silently checkout a local branch when a remote ref no longer exists", () => + it.effect("does not silently checkout a local branch when a remote ref no longer exists", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -1018,7 +1010,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("checks out a remote tracking branch when remote name contains slashes", () => + it.effect("checks out a remote tracking branch when remote name contains slashes", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const prefixRemote = yield* makeTmpDir(); @@ -1083,7 +1075,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect( + it.effect( "falls back to detached checkout when --track would conflict with an existing local branch", () => Effect.gen(function* () { @@ -1113,7 +1105,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("throws when checkout would overwrite uncommitted changes", () => + it.effect("throws when checkout would overwrite uncommitted changes", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1151,7 +1143,7 @@ it.layer(TestLayer)("git integration", (it) => { // ── createGitBranch ── describe("createGitBranch", () => { - effect("creates a new branch visible in listGitBranches", () => + it.effect("creates a new branch visible in listGitBranches", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1162,7 +1154,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("throws when branch already exists", () => + it.effect("throws when branch already exists", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1178,7 +1170,7 @@ it.layer(TestLayer)("git integration", (it) => { // ── renameGitBranch ── describe("renameGitBranch", () => { - effect("renames the current branch", () => + it.effect("renames the current branch", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1200,7 +1192,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("returns success without git invocation when old/new names match", () => + it.effect("returns success without git invocation when old/new names match", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1218,7 +1210,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("appends numeric suffix when target branch already exists", () => + it.effect("appends numeric suffix when target branch already exists", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1245,7 +1237,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("increments suffix until it finds an available branch name", () => + it.effect("increments suffix until it finds an available branch name", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1264,7 +1256,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("uses '--' separator for branch rename arguments", () => + it.effect("uses '--' separator for branch rename arguments", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1295,7 +1287,7 @@ it.layer(TestLayer)("git integration", (it) => { // ── createGitWorktree + removeGitWorktree ── describe("createGitWorktree", () => { - effect("creates a worktree with a new branch from the base branch", () => + it.effect("creates a worktree with a new branch from the base branch", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1322,7 +1314,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("worktree has the new branch checked out", () => + it.effect("worktree has the new branch checked out", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1347,7 +1339,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("creates a worktree for an existing branch when newBranch is omitted", () => + it.effect("creates a worktree for an existing branch when newBranch is omitted", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1369,7 +1361,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("throws when new branch name already exists", () => + it.effect("throws when new branch name already exists", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1392,7 +1384,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("listGitBranches from worktree cwd reports worktree branch as current", () => + it.effect("listGitBranches from worktree cwd reports worktree branch as current", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1424,7 +1416,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("removeGitWorktree cleans up the worktree", () => + it.effect("removeGitWorktree cleans up the worktree", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1447,7 +1439,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("removeGitWorktree force removes a dirty worktree", () => + it.effect("removeGitWorktree force removes a dirty worktree", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1482,7 +1474,7 @@ it.layer(TestLayer)("git integration", (it) => { // ── Full flow: local branch checkout ── describe("full flow: local branch checkout", () => { - effect("init → commit → create branch → checkout → verify current", () => + it.effect("init → commit → create branch → checkout → verify current", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1499,7 +1491,7 @@ it.layer(TestLayer)("git integration", (it) => { // ── Full flow: worktree creation from base branch ── describe("full flow: worktree creation", () => { - effect("creates worktree with new branch from current branch", () => + it.effect("creates worktree with new branch from current branch", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1534,7 +1526,7 @@ it.layer(TestLayer)("git integration", (it) => { }); describe("fetchPullRequestBranch", () => { - effect("fetches a GitHub pull request ref into a local branch without checkout", () => + it.effect("fetches a GitHub pull request ref into a local branch without checkout", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(tmp); @@ -1567,7 +1559,7 @@ it.layer(TestLayer)("git integration", (it) => { // ── Full flow: thread switching simulation ── describe("full flow: thread switching (checkout toggling)", () => { - effect("checkout a → checkout b → checkout a → current matches", () => + it.effect("checkout a → checkout b → checkout a → current matches", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1595,7 +1587,7 @@ it.layer(TestLayer)("git integration", (it) => { // ── Full flow: checkout conflict ── describe("full flow: checkout conflict", () => { - effect("uncommitted changes prevent checkout to a diverged branch", () => + it.effect("uncommitted changes prevent checkout to a diverged branch", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1629,7 +1621,7 @@ it.layer(TestLayer)("git integration", (it) => { }); describe("GitCore", () => { - effect("supports branch lifecycle operations through the service API", () => + it.effect("supports branch lifecycle operations through the service API", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const core = yield* GitCore; @@ -1653,7 +1645,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect( + it.effect( "reuses an existing remote when the target URL only differs by a trailing slash after .git", () => Effect.gen(function* () { @@ -1674,7 +1666,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("reports status details and dirty state", () => + it.effect("reports status details and dirty state", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1690,7 +1682,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("returns a non-repo status for deleted directories", () => + it.effect("returns a non-repo status for deleted directories", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const deletedDir = path.join(tmp, "deleted-repo"); @@ -1721,7 +1713,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("computes ahead count against base branch when no upstream is configured", () => + it.effect("computes ahead count against base branch when no upstream is configured", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1741,37 +1733,39 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("computes ahead count against origin/default when local default branch is missing", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); + it.effect( + "computes ahead count against origin/default when local default branch is missing", + () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); - yield* git(source, ["checkout", "-b", "feature/remote-base-only"]); - yield* writeTextFile( - path.join(source, "feature.txt"), - `ahead of origin/${initialBranch}\n`, - ); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature commit"]); - yield* git(source, ["branch", "-D", initialBranch]); + yield* initRepoWithCommit(source); + const initialBranch = (yield* (yield* GitCore).listBranches({ + cwd: source, + })).branches.find((branch) => branch.current)!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", initialBranch]); + yield* git(source, ["checkout", "-b", "feature/remote-base-only"]); + yield* writeTextFile( + path.join(source, "feature.txt"), + `ahead of origin/${initialBranch}\n`, + ); + yield* git(source, ["add", "feature.txt"]); + yield* git(source, ["commit", "-m", "feature commit"]); + yield* git(source, ["branch", "-D", initialBranch]); - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe("feature/remote-base-only"); - expect(details.hasUpstream).toBe(false); - expect(details.aheadCount).toBe(1); - expect(details.behindCount).toBe(0); - }), + const core = yield* GitCore; + const details = yield* core.statusDetails(source); + expect(details.branch).toBe("feature/remote-base-only"); + expect(details.hasUpstream).toBe(false); + expect(details.aheadCount).toBe(1); + expect(details.behindCount).toBe(0); + }), ); - effect( + it.effect( "computes ahead count against a non-origin remote-prefixed gh-merge-base candidate", () => Effect.gen(function* () { @@ -1809,7 +1803,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("skips push when no upstream is configured and branch is not ahead of base", () => + it.effect("skips push when no upstream is configured and branch is not ahead of base", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -1825,7 +1819,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("pushes with upstream setup when no comparable base branch exists", () => + it.effect("pushes with upstream setup when no comparable base branch exists", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const remote = yield* makeTmpDir(); @@ -1850,7 +1844,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("pushes with upstream setup to the only configured non-origin remote", () => + it.effect("pushes with upstream setup to the only configured non-origin remote", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const remote = yield* makeTmpDir(); @@ -1875,7 +1869,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect( + it.effect( "pushes with upstream setup when comparable base exists but remote branch is missing", () => Effect.gen(function* () { @@ -1911,7 +1905,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("prefers branch pushRemote over origin when setting upstream", () => + it.effect("prefers branch pushRemote over origin when setting upstream", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const origin = yield* makeTmpDir(); @@ -1948,7 +1942,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect( + it.effect( "pushes renamed PR worktree branches to their tracked upstream branch even when push.default is current", () => Effect.gen(function* () { @@ -1995,7 +1989,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("pushes to the tracked upstream when the remote name contains slashes", () => + it.effect("pushes to the tracked upstream when the remote name contains slashes", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const remote = yield* makeTmpDir(); @@ -2036,7 +2030,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("includes command context when worktree removal fails", () => + it.effect("includes command context when worktree removal fails", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -2057,38 +2051,46 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("refreshes upstream before statusDetails so behind count reflects remote updates", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); + it.effect( + "refreshes upstream before statusDetails so behind count reflects remote updates", + () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + const clone = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); + yield* initRepoWithCommit(source); + const initialBranch = (yield* (yield* GitCore).listBranches({ + cwd: source, + })).branches.find((branch) => branch.current)!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", initialBranch]); - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* git(clone, ["checkout", "-B", initialBranch, "--track", `origin/${initialBranch}`]); - yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); - yield* git(clone, ["add", "CHANGELOG.md"]); - yield* git(clone, ["commit", "-m", "remote update"]); - yield* git(clone, ["push", "origin", initialBranch]); + yield* git(clone, ["clone", remote, "."]); + yield* git(clone, ["config", "user.email", "test@test.com"]); + yield* git(clone, ["config", "user.name", "Test"]); + yield* git(clone, [ + "checkout", + "-B", + initialBranch, + "--track", + `origin/${initialBranch}`, + ]); + yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); + yield* git(clone, ["add", "CHANGELOG.md"]); + yield* git(clone, ["commit", "-m", "remote update"]); + yield* git(clone, ["push", "origin", initialBranch]); - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe(initialBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }), + const core = yield* GitCore; + const details = yield* core.statusDetails(source); + expect(details.branch).toBe(initialBranch); + expect(details.aheadCount).toBe(0); + expect(details.behindCount).toBe(1); + }), ); - effect("prepares commit context by auto-staging and creates commit", () => + it.effect("prepares commit context by auto-staging and creates commit", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -2106,7 +2108,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("prepareCommitContext stages only selected files when filePaths provided", () => + it.effect("prepareCommitContext stages only selected files when filePaths provided", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -2129,7 +2131,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("prepareCommitContext stages everything when filePaths is undefined", () => + it.effect("prepareCommitContext stages everything when filePaths is undefined", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -2145,7 +2147,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("prepareCommitContext truncates oversized staged patches instead of failing", () => + it.effect("prepareCommitContext truncates oversized staged patches instead of failing", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -2160,7 +2162,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("readRangeContext truncates oversized diff patches instead of failing", () => + it.effect("readRangeContext truncates oversized diff patches instead of failing", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(tmp); @@ -2179,7 +2181,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("pushes with upstream setup and then skips when up to date", () => + it.effect("pushes with upstream setup and then skips when up to date", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const remote = yield* makeTmpDir(); @@ -2207,7 +2209,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("pulls behind branch and then reports up-to-date", () => + it.effect("pulls behind branch and then reports up-to-date", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -2239,7 +2241,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("top-level pullGitBranch rejects when no upstream exists", () => + it.effect("top-level pullGitBranch rejects when no upstream exists", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -2251,7 +2253,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("lists branches when recency lookup fails", () => + it.effect("lists branches when recency lookup fails", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); @@ -2281,7 +2283,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - effect("falls back to empty remote branch data when remote lookups fail", () => + it.effect("falls back to empty remote branch data when remote lookups fail", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const remote = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index ee6b70349a5..fd991273d1a 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -671,14 +671,7 @@ const GitManagerTestLayer = GitCoreLive.pipe( ); it.layer(GitManagerTestLayer)("GitManager", (it) => { - const LONG_EFFECT_TEST_TIMEOUT_MS = 30_000; - const effect = ( - name: string, - test: Parameters[1], - timeout = LONG_EFFECT_TEST_TIMEOUT_MS, - ) => it.effect(name, test, timeout); - - effect("status includes PR metadata when branch already has an open PR", () => + it.effect("status includes PR metadata when branch already has an open PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -719,7 +712,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("status trims PR metadata returned by gh before publishing it", () => + it.effect("status trims PR metadata returned by gh before publishing it", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -757,7 +750,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("status ignores invalid gh pr list entries and keeps valid ones", () => + it.effect("status ignores invalid gh pr list entries and keeps valid ones", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -808,7 +801,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("status preserves lowercase merged and closed PR states from gh json", () => + it.effect("status preserves lowercase merged and closed PR states from gh json", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -857,7 +850,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("status returns an explicit non-repo result for non-git directories", () => + it.effect("status returns an explicit non-repo result for non-git directories", () => Effect.gen(function* () { const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); const { manager } = yield* makeManager(); @@ -883,7 +876,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("status returns an explicit non-repo result for deleted directories", () => + it.effect("status returns an explicit non-repo result for deleted directories", () => Effect.gen(function* () { const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); const cwd = path.join(rootDir, "deleted-repo"); @@ -912,7 +905,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("status briefly caches repeated lookups for the same cwd", () => + it.effect("status briefly caches repeated lookups for the same cwd", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -943,7 +936,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect( + it.effect( "status ignores unrelated fork PRs when the current branch tracks the same repository", () => Effect.gen(function* () { @@ -984,7 +977,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect( + it.effect( "status detects cross-repo PRs from the upstream remote URL owner", () => Effect.gen(function* () { @@ -1046,10 +1039,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), - LONG_EFFECT_TEST_TIMEOUT_MS, + 20_000, ); - effect( + it.effect( "status ignores synthetic local branch aliases when the upstream remote name contains slashes", () => Effect.gen(function* () { @@ -1154,10 +1147,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ), ).toBe(false); }), - LONG_EFFECT_TEST_TIMEOUT_MS, + 20_000, ); - effect("status returns merged PR state when latest PR was merged", () => + it.effect("status returns merged PR state when latest PR was merged", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1195,7 +1188,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("status prefers open PR when merged PR has newer updatedAt", () => + it.effect("status prefers open PR when merged PR has newer updatedAt", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1242,7 +1235,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("status is resilient to gh lookup failures and returns pr null", () => + it.effect("status is resilient to gh lookup failures and returns pr null", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1266,7 +1259,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("creates a commit when working tree is dirty", () => + it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1301,7 +1294,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("uses custom commit message when provided", () => + it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1344,7 +1337,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("commits only selected files when filePaths is provided", () => + it.effect("commits only selected files when filePaths is provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1369,7 +1362,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("creates feature branch, commits, and pushes with featureBranch option", () => + it.effect("creates feature branch, commits, and pushes with featureBranch option", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1432,7 +1425,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("featureBranch uses custom commit message and derives branch name", () => + it.effect("featureBranch uses custom commit message and derives branch name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1475,7 +1468,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("skips commit when there are no uncommitted changes", () => + it.effect("skips commit when there are no uncommitted changes", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1493,7 +1486,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("featureBranch returns error when worktree is clean", () => + it.effect("featureBranch returns error when worktree is clean", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1512,7 +1505,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("commits and pushes with upstream auto-setup when needed", () => + it.effect("commits and pushes with upstream auto-setup when needed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1539,7 +1532,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect( + it.effect( "pushes and creates PR from a no-upstream branch when local commits are ahead of base", () => Effect.gen(function* () { @@ -1590,7 +1583,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("skips push when branch is already up to date", () => + it.effect("skips push when branch is already up to date", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1611,7 +1604,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("pushes existing clean commits without rerunning commit logic", () => + it.effect("pushes existing clean commits without rerunning commit logic", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1639,7 +1632,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("create_pr pushes a clean branch before creating the PR when needed", () => + it.effect("create_pr pushes a clean branch before creating the PR when needed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1685,7 +1678,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("returns existing PR metadata for commit/push/pr action", () => + it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -1730,7 +1723,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect( + it.effect( "returns existing cross-repo PR metadata using the fork owner selector", () => Effect.gen(function* () { @@ -1785,10 +1778,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), - LONG_EFFECT_TEST_TIMEOUT_MS, + 12_000, ); - effect( + it.effect( "returns the correct existing PR when a slash remote checks out to a synthetic local alias", () => Effect.gen(function* () { @@ -1877,10 +1870,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { false, ); }), - LONG_EFFECT_TEST_TIMEOUT_MS, + 20_000, ); - effect( + it.effect( "prefers owner-qualified selectors before bare branch names for cross-repo PRs", () => Effect.gen(function* () { @@ -1947,10 +1940,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), - LONG_EFFECT_TEST_TIMEOUT_MS, + 12_000, ); - effect( + it.effect( "stops probing head selectors after finding an existing PR", () => Effect.gen(function* () { @@ -2009,10 +2002,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { "pr list --head octocat:statemachine --state open --limit 1", ); }), - LONG_EFFECT_TEST_TIMEOUT_MS, + 12_000, ); - effect("creates PR when one does not already exist", () => + it.effect("creates PR when one does not already exist", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2057,75 +2050,79 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("creates a new PR instead of reusing an unrelated fork PR with the same head branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); + it.effect( + "creates a new PR instead of reusing an unrelated fork PR with the same head branch", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 1661, - title: "Fork PR with same branch name", - url: "https://github.com/pingdotgg/t3code/pull/1661", - baseRefName: "main", - headRefName: "feature/no-fork-match", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "lnieuwenhuis/t3code", + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 1661, + title: "Fork PR with same branch name", + url: "https://github.com/pingdotgg/t3code/pull/1661", + baseRefName: "main", + headRefName: "feature/no-fork-match", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "lnieuwenhuis/t3code", + }, + headRepositoryOwner: { + login: "lnieuwenhuis", + }, }, - headRepositoryOwner: { - login: "lnieuwenhuis", + ]), + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + baseRefName: "main", + headRefName: "feature/no-fork-match", + state: "OPEN", + isCrossRepository: false, }, - }, - ]), - JSON.stringify([ - { - number: 188, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - baseRefName: "main", - headRefName: "feature/no-fork-match", - state: "OPEN", - isCrossRepository: false, - }, - ]), - ], - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(188); - expect(result.toast).toEqual({ - title: "Created PR #188", - description: "Add stacked git actions", - cta: { - kind: "open_pr", - label: "View PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - }, - }); - expect( - ghCalls.some((call) => call.includes("pr create --base main --head feature/no-fork-match")), - ).toBe(true); - }), + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(188); + expect(result.toast).toEqual({ + title: "Created PR #188", + description: "Add stacked git actions", + cta: { + kind: "open_pr", + label: "View PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + }, + }); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/no-fork-match"), + ), + ).toBe(true); + }), ); - effect("creates cross-repo PRs with the fork owner selector and default base branch", () => + it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2191,7 +2188,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("rejects push/pr actions from detached HEAD", () => + it.effect("rejects push/pr actions from detached HEAD", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2209,7 +2206,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("surfaces missing gh binary errors", () => + it.effect("surfaces missing gh binary errors", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2238,7 +2235,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("surfaces gh auth errors with guidance", () => + it.effect("surfaces gh auth errors with guidance", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2267,7 +2264,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("resolves pull requests from #number references", () => + it.effect("resolves pull requests from #number references", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2302,7 +2299,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("prepares pull request threads in local mode by checking out the PR branch", () => + it.effect("prepares pull request threads in local mode by checking out the PR branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2338,7 +2335,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("prepares pull request threads in worktree mode on the PR head branch", () => + it.effect("prepares pull request threads in worktree mode on the PR head branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2498,7 +2495,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("preserves fork upstream tracking when preparing a local PR thread", () => + it.effect("preserves fork upstream tracking when preparing a local PR thread", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2551,7 +2548,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => + it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2608,7 +2605,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("reuses an existing dedicated worktree for the PR head branch", () => + it.effect("reuses an existing dedicated worktree for the PR head branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2656,7 +2653,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect( + it.effect( "does not block fork PR worktree prep when the fork head branch collides with root main", () => Effect.gen(function* () { @@ -2716,65 +2713,67 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("does not overwrite an existing local main branch when preparing a fork PR worktree", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); - yield* runGit(repoDir, ["add", "fork-main-second.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); - yield* runGit(repoDir, ["checkout", "main"]); - const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); - yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]); + it.effect( + "does not overwrite an existing local main branch when preparing a fork PR worktree", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); + fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); + yield* runGit(repoDir, ["add", "fork-main-second.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); + yield* runGit(repoDir, ["checkout", "main"]); + const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]); - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 92, - title: "Fork main overwrite PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/92", - baseRefName: "main", - headRefName: "main", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: forkDir, - sshUrl: forkDir, + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 92, + title: "Fork main overwrite PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/92", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, }, }, - }, - }); + }); - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "92", - mode: "worktree", - }); + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "92", + mode: "worktree", + }); - expect(result.branch).toBe("t3code/pr-92/main"); - expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); - expect( - (yield* runGit(result.worktreePath as string, [ - "rev-parse", - "--abbrev-ref", - "@{upstream}", - ])).stdout.trim(), - ).toBe("fork-seed/main"); - }), + expect(result.branch).toBe("t3code/pr-92/main"); + expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); + expect( + (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(), + ).toBe("fork-seed/main"); + }), ); - effect("reuses an existing PR worktree and restores fork upstream tracking", () => + it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2906,7 +2905,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("emits ordered progress events for commit hooks", () => + it.effect("emits ordered progress events for commit hooks", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2969,7 +2968,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - effect("emits action_failed when a commit hook rejects", () => + it.effect("emits action_failed when a commit hook rejects", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 14c5a03d9eb..12e11450dd3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -121,7 +121,7 @@ async function waitForThread( checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; activities: ReadonlyArray<{ kind: string }>; }) => boolean, - timeoutMs = 30_000, + timeoutMs = 15_000, ) { const deadline = Date.now() + timeoutMs; const poll = async (): Promise<{ @@ -146,7 +146,7 @@ async function waitForThread( async function waitForEvent( engine: OrchestrationEngineShape, predicate: (event: { type: string }) => boolean, - timeoutMs = 30_000, + timeoutMs = 15_000, ) { const deadline = Date.now() + timeoutMs; const poll = async () => { @@ -197,7 +197,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { return runGit(cwd, ["show", `${ref}:${filePath}`]); } -async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 30_000) { +async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) { const deadline = Date.now() + timeoutMs; const poll = async (): Promise => { if (gitRefExists(cwd, ref)) { @@ -213,9 +213,6 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 30_000) } describe("CheckpointReactor", () => { - const LONG_TEST_TIMEOUT_MS = 30_000; - const test = (name: string, run: () => Promise, timeout = LONG_TEST_TIMEOUT_MS) => - it(name, run, timeout); let runtime: ManagedRuntime.ManagedRuntime< OrchestrationEngineService | CheckpointReactor | CheckpointStore, unknown @@ -374,7 +371,7 @@ describe("CheckpointReactor", () => { }; } - test("captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", async () => { + it("captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); const createdAt = new Date().toISOString(); @@ -544,7 +541,7 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); }); - test("captures pre-turn and completion checkpoints for claude runtime events", async () => { + it("captures pre-turn and completion checkpoints for claude runtime events", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false, providerName: "claudeAgent", @@ -605,7 +602,7 @@ describe("CheckpointReactor", () => { ).toBe(true); }); - test("appends capture failure activity when turn diff summary cannot be derived", async () => { + it("appends capture failure activity when turn diff summary cannot be derived", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); const createdAt = new Date().toISOString(); @@ -652,7 +649,7 @@ describe("CheckpointReactor", () => { ).toBe(true); }); - test("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { + it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { const harness = await createHarness({ hasSession: false, seedFilesystemCheckpoints: false, @@ -689,7 +686,7 @@ describe("CheckpointReactor", () => { ).toBe("v1\n"); }); - test("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { + it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { const harness = await createHarness({ hasSession: false, seedFilesystemCheckpoints: false, @@ -740,7 +737,7 @@ describe("CheckpointReactor", () => { ).toBe("v2\n"); }); - test("ignores non-v2 checkpoint.captured runtime events", async () => { + it("ignores non-v2 checkpoint.captured runtime events", async () => { const harness = await createHarness(); const createdAt = new Date().toISOString(); @@ -782,7 +779,7 @@ describe("CheckpointReactor", () => { ); }); - test("continues processing runtime events after a single checkpoint runtime failure", async () => { + it("continues processing runtime events after a single checkpoint runtime failure", async () => { const nonRepositorySessionCwd = fs.mkdtempSync( path.join(os.tmpdir(), "t3-checkpoint-runtime-non-repo-"), ); @@ -842,7 +839,7 @@ describe("CheckpointReactor", () => { ).toBe(true); }); - test("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { + it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { const harness = await createHarness(); const createdAt = new Date().toISOString(); @@ -920,7 +917,7 @@ describe("CheckpointReactor", () => { ).toBe(false); }); - test("executes provider revert and emits thread.reverted for claude sessions", async () => { + it("executes provider revert and emits thread.reverted for claude sessions", async () => { const harness = await createHarness({ providerName: "claudeAgent" }); const createdAt = new Date().toISOString(); @@ -989,7 +986,7 @@ describe("CheckpointReactor", () => { }); }); - test("processes consecutive revert requests with deterministic rollback sequencing", async () => { + it("processes consecutive revert requests with deterministic rollback sequencing", async () => { const harness = await createHarness(); const createdAt = new Date().toISOString(); @@ -1072,7 +1069,7 @@ describe("CheckpointReactor", () => { }); }); - test("appends an error activity when revert is requested without an active session", async () => { + it("appends an error activity when revert is requested without an active session", async () => { const harness = await createHarness({ hasSession: false }); const createdAt = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 50344233ff1..c6c9b22abb1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -13,7 +13,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Deferred, Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; import { deriveServerPaths, ServerConfig } from "../../config.ts"; @@ -357,97 +357,6 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); - it("records session lastError and clears active turn when provider turn start fails", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - - harness.sendTurn.mockImplementation( - () => - Effect.fail( - new ProviderAdapterRequestError({ - provider: "cursor", - method: "session/set_config_option", - detail: 'Invalid value for session config option "model"', - }), - ) as never, - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-session-error"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-session-error"), - role: "user", - text: "hello", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return ( - thread?.session?.lastError === - 'Provider adapter request failed (cursor) for session/set_config_option: Invalid value for session config option "model"' && - thread?.session?.status === "ready" && - thread?.session?.activeTurnId === null - ); - }); - - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session).toMatchObject({ - status: "ready", - activeTurnId: null, - lastError: - 'Provider adapter request failed (cursor) for session/set_config_option: Invalid value for session config option "model"', - }); - }); - - it("does not record shutdown-style interrupts as session errors", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - - harness.sendTurn.mockImplementation(() => Effect.interrupt as never); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-interrupt-only"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-interrupt-only"), - role: "user", - text: "hello", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - await new Promise((resolve) => setTimeout(resolve, 50)); - - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session).toMatchObject({ - status: "ready", - activeTurnId: null, - lastError: null, - }); - expect( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed"), - ).toBe(false); - }); - it("generates a thread title on the first turn", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1061,63 +970,6 @@ describe("ProviderCommandReactor", () => { }); }); - it("switches cursor model in-session without restarting", async () => { - const harness = await createHarness({ - threadModelSelection: { provider: "cursor", model: "composer-2" }, - }); - const now = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-cursor-model-1"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-cursor-model-1"), - role: "user", - text: "first cursor turn", - attachments: [], - }, - modelSelection: { provider: "cursor", model: "composer-2" }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.startSession.mock.calls.length === 1); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-cursor-model-2"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-cursor-model-2"), - role: "user", - text: "second cursor turn", - attachments: [], - }, - modelSelection: { - provider: "cursor", - model: "composer-2", - options: { fastMode: true }, - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.sendTurn.mock.calls.length === 2); - - expect(harness.startSession.mock.calls.length).toBe(1); - expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, - }); - }); - it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1478,108 +1330,6 @@ describe("ProviderCommandReactor", () => { }); }); - it("preserves provider method context when turn start fails", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - - harness.sendTurn.mockImplementation( - () => - Effect.fail( - new ProviderAdapterRequestError({ - provider: "cursor", - method: "session/set_config_option", - detail: "Invalid cursor/set_config_option payload: Expected string, got null", - }), - ) as never, - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-error"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-provider-error"), - role: "user", - text: "hello", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); - }); - - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining( - "Provider adapter request failed (cursor) for session/set_config_option", - ), - }, - }); - }); - - it("keeps the full rendered cause for non-adapter turn start failures", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - - harness.sendTurn.mockImplementation( - () => - Effect.fail( - new Error("Invalid params", { cause: new Error("session/prompt failed") }), - ) as never, - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-raw-error"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-raw-error"), - role: "user", - text: "hello", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); - }); - - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining("session/prompt failed"), - }, - }); - }); - it("reacts to thread.approval.respond by forwarding provider approval response", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1621,60 +1371,6 @@ describe("ProviderCommandReactor", () => { }); }); - it("processes approval responses while a provider turn is still in flight", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - const unblockSendTurn = Effect.runSync(Deferred.make()); - - harness.sendTurn.mockImplementation(() => - Deferred.await(unblockSendTurn).pipe( - Effect.as({ - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-1"), - }), - ), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-blocked-approval"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-blocked-approval"), - role: "user", - text: "need approval while turn is running", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.approval.respond", - commandId: CommandId.make("cmd-approval-respond-during-send-turn"), - threadId: ThreadId.make("thread-1"), - requestId: asApprovalRequestId("approval-request-while-send-turn-blocked"), - decision: "acceptForSession", - createdAt: now, - }), - ); - - await waitFor(() => harness.respondToRequest.mock.calls.length === 1); - expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ - threadId: "thread-1", - requestId: "approval-request-while-send-turn-blocked", - decision: "acceptForSession", - }); - - await Effect.runPromise(Deferred.succeed(unblockSendTurn, undefined)); - }); - it("reacts to thread.user-input.respond by forwarding structured user input answers", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 4a802ad10ca..d068335314b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -7,7 +7,6 @@ import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; import { ServerConfig } from "../../config.ts"; -import { isCursorEnabled } from "../../cursorFeatureFlag.ts"; import { ClaudeProviderLive } from "./ClaudeProvider.ts"; import { CodexProviderLive } from "./CodexProvider.ts"; import { CursorProviderLive } from "./CursorProvider.ts"; @@ -95,18 +94,9 @@ const ProviderRegistryLiveBase = Layer.effect( const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const cursorEnabled = isCursorEnabled(); - const cursorProviderOption = yield* Effect.serviceOption(CursorProvider); - const cursorProvider = - cursorEnabled && cursorProviderOption._tag === "Some" ? cursorProviderOption.value : null; - const cursorProviderSource = cursorProvider - ? ({ - provider: "cursor", - getSnapshot: cursorProvider.getSnapshot, - refresh: cursorProvider.refresh, - streamChanges: cursorProvider.streamChanges, - } satisfies ProviderSnapshotSource) - : null; + + const cursorProvider = yield* CursorProvider; + const providerSources = [ { provider: "codex", @@ -126,11 +116,14 @@ const ProviderRegistryLiveBase = Layer.effect( refresh: openCodeProvider.refresh, streamChanges: openCodeProvider.streamChanges, }, - ...(cursorProviderSource ? [cursorProviderSource] : []), + { + provider: "cursor", + getSnapshot: cursorProvider.getSnapshot, + refresh: cursorProvider.refresh, + streamChanges: cursorProvider.streamChanges, + }, ] satisfies ReadonlyArray; - const activeProviders = PROVIDER_CACHE_IDS.filter( - (provider) => provider !== "cursor" || cursorEnabled, - ); + const activeProviders = PROVIDER_CACHE_IDS; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, @@ -289,10 +282,8 @@ const ProviderRegistryLiveBase = Layer.effect( export const ProviderRegistryLive = Layer.unwrap( Effect.sync(() => - (isCursorEnabled() - ? ProviderRegistryLiveBase.pipe(Layer.provideMerge(CursorProviderLive)) - : ProviderRegistryLiveBase - ).pipe( + ProviderRegistryLiveBase.pipe( + Layer.provideMerge(CursorProviderLive), Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive), Layer.provideMerge(OpenCodeProviderLive), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 12a7dc6d364..2a57c9afc42 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -51,7 +51,6 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; -import { isCursorEnabled } from "./cursorFeatureFlag.ts"; import { authBearerBootstrapRouteLayer, authBootstrapRouteLayer, @@ -163,15 +162,13 @@ const ProviderLayerLive = Layer.unwrap( const cursorAdapterLayer = makeCursorAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); - const adapterRegistryBase = ProviderAdapterRegistryLive.pipe( + const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), Layer.provide(openCodeAdapterLayer), + Layer.provide(cursorAdapterLayer), Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); - const adapterRegistryLayer = isCursorEnabled() - ? adapterRegistryBase.pipe(Layer.provide(cursorAdapterLayer)) - : adapterRegistryBase; return makeProviderServiceLive( canonicalEventLogger ? { canonicalEventLogger } : undefined, ).pipe( From 489c2c9f94740d5ca9ef6599cbb0e1162d1ee2a9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 14:16:39 -0700 Subject: [PATCH 67/82] Move Claude API model ID resolution into provider - Resolve `[1m]` suffix only for Claude selections in the provider - Remove shared `resolveApiModelId` helper and its tests --- .../src/provider/Layers/ClaudeProvider.ts | 8 +++-- packages/shared/src/model.test.ts | 36 ------------------- packages/shared/src/model.ts | 23 ------------ 3 files changed, 6 insertions(+), 61 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 076500b3060..2ffe045407b 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -10,7 +10,6 @@ import type { } from "@t3tools/contracts"; import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { resolveApiModelId } from "@t3tools/shared/model"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { query as claudeQuery, @@ -149,7 +148,12 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo } export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { - return resolveApiModelId(modelSelection); + switch (modelSelection.options?.contextWindow) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; + } } export function parseClaudeAuthStatusFromOutput(result: CommandResult): { readonly status: Exclude; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index bd7ebf5cc8f..426ceca865e 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -11,7 +11,6 @@ import { normalizeClaudeModelOptionsWithCapabilities, normalizeCodexModelOptionsWithCapabilities, normalizeModelSlug, - resolveApiModelId, resolveContextWindow, resolveEffort, resolveModelSlugForProvider, @@ -196,41 +195,6 @@ describe("resolveContextWindow", () => { }); }); -describe("resolveApiModelId", () => { - it("appends [1m] suffix for 1m context window", () => { - expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "1m" }, - }), - ).toBe("claude-opus-4-6[1m]"); - }); - - it("returns the model as-is for 200k context window", () => { - expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "200k" }, - }), - ).toBe("claude-opus-4-6"); - }); - - it("returns the model as-is when no context window is set", () => { - expect(resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( - "claude-opus-4-6", - ); - expect( - resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6", options: {} }), - ).toBe("claude-opus-4-6"); - }); - - it("returns the model as-is for Codex selections", () => { - expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4"); - }); -}); - describe("normalize*ModelOptionsWithCapabilities", () => { it("preserves explicit false codex fast mode", () => { expect( diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index fcb7d1d15bd..d15a3a6c333 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -282,29 +282,6 @@ export function createModelSelection( } } -/** - * Resolve the actual API model identifier from a model selection. - * - * Provider-aware: each provider can map `contextWindow` (or other options) - * to whatever the API requires. The canonical slug stored in the selection - * stays unchanged so the capabilities system keeps working. - */ -export function resolveApiModelId(modelSelection: ModelSelection): string { - switch (modelSelection.provider) { - case "claudeAgent": { - switch (modelSelection.options?.contextWindow) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - return modelSelection.model; - } - } - default: { - return modelSelection.model; - } - } -} - export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeAgentEffort | null | undefined, From 32df4e7f42a30d98c81270a370053c643a94ccfc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 14:22:07 -0700 Subject: [PATCH 68/82] Remove duplicate model and layer providers - Deduplicate Cursor and OpenCode provider wiring - Fix orchestration test runtime setup --- apps/server/src/git/Layers/RoutingTextGeneration.ts | 2 -- .../src/orchestration/Layers/ProviderCommandReactor.test.ts | 2 +- packages/contracts/src/orchestration.ts | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index f8c803fac2b..8f5c166d817 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -109,6 +109,4 @@ export const RoutingTextGenerationLive = Layer.effect( Layer.provide(InternalClaudeLayer), Layer.provide(InternalCursorLayer), Layer.provide(InternalOpenCodeLayer), - Layer.provide(InternalCursorLayer), - Layer.provide(InternalOpenCodeLayer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c6c9b22abb1..dfdfab926f8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -266,7 +266,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); - const runtime = ManagedRuntime.make(layer); + runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 39746fe366f..a3636fb2b10 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -80,8 +80,6 @@ export const ModelSelection = Schema.Union([ ClaudeModelSelection, CursorModelSelection, OpenCodeModelSelection, - CursorModelSelection, - OpenCodeModelSelection, ]); export type ModelSelection = typeof ModelSelection.Type; From c0436c6e6c739a3dce35800ea881432ba5491fc2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 14:33:17 -0700 Subject: [PATCH 69/82] Refactor provider model picker option handling - Use shared provider option constants in the composer - Simplify picker selection state and unavailable option rendering - Align thinking toggle selection with explicit model settings --- apps/web/src/components/chat/ChatComposer.tsx | 30 +++++----- .../CompactComposerControlsMenu.browser.tsx | 34 +++++------ .../chat/ProviderModelPicker.browser.tsx | 20 ------- .../components/chat/ProviderModelPicker.tsx | 58 +++++-------------- apps/web/src/components/chat/TraitsPicker.tsx | 4 +- 5 files changed, 47 insertions(+), 99 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1499f0aa7d1..4cc2c85cae6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -59,7 +59,7 @@ import { shouldUseCompactComposerFooter, } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; -import { getAvailableProviderOptions, ProviderModelPicker } from "./ProviderModelPicker"; +import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -624,20 +624,20 @@ export const ChatComposer = memo( }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => - getAvailableProviderOptions(providerStatuses) - .filter((option) => lockedProvider === null || option.value === lockedProvider) - .flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), - })), - ), - [lockedProvider, modelOptionsByProvider, providerStatuses], + AVAILABLE_PROVIDER_OPTIONS.filter( + (option) => lockedProvider === null || option.value === lockedProvider, + ).flatMap((option) => + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ + provider: option.value, + providerLabel: option.label, + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + searchProvider: option.label.toLowerCase(), + })), + ), + [lockedProvider, modelOptionsByProvider], ); // ------------------------------------------------------------------ diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 4d4269330c3..7619a635545 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -102,25 +102,23 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str }, }, ] - : provider === "codex" - ? [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + : [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], }, - ] - : []; + }, + ]; const screen = await render( { await mounted.cleanup(); } }); - - it("hides cursor when the server does not expose it", async () => { - const mounted = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", - lockedProvider: null, - providers: TEST_PROVIDERS, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).not.toContain("Cursor"); - }); - } finally { - await mounted.cleanup(); - } - }); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 21f346bdfb2..8b20237a83d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,5 +1,5 @@ import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; -import { resolveModelSlugForProvider, resolveSelectableModel } from "@t3tools/shared/model"; +import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; @@ -48,25 +48,6 @@ function providerIconClassName( return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; } -function isHiddenProviderOption( - option: (typeof PROVIDER_OPTIONS)[number], - providers: ReadonlyArray | undefined, -): boolean { - return ( - option.value === "cursor" && - (providers?.length ?? 0) > 0 && - getProviderSnapshot(providers ?? [], option.value) === undefined - ); -} - -export function getAvailableProviderOptions(providers?: ReadonlyArray) { - return AVAILABLE_PROVIDER_OPTIONS.filter((option) => !isHiddenProviderOption(option, providers)); -} - -export function getUnavailableProviderOptions() { - return UNAVAILABLE_PROVIDER_OPTIONS; -} - export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: string; @@ -78,26 +59,22 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { disabled?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; - disabledReason?: string; onProviderModelChange: (provider: ProviderKind, model: string) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; - const availableProviderOptions = getAvailableProviderOptions(props.providers); - const unavailableProviderOptions = getUnavailableProviderOptions(); const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; - const selectedModelValue = - resolveSelectableModel(activeProvider, props.model, selectedProviderOptions) ?? props.model; const selectedModelLabel = - selectedProviderOptions.find((option) => option.slug === selectedModelValue)?.name ?? - props.model; + selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; const handleModelChange = (provider: ProviderKind, value: string) => { if (props.disabled) return; if (!value) return; - const resolvedModel = - resolveSelectableModel(provider, value, props.modelOptionsByProvider[provider]) ?? - resolveModelSlugForProvider(provider, value); + const resolvedModel = resolveSelectableModel( + provider, + value, + props.modelOptionsByProvider[provider], + ); if (!resolvedModel) return; props.onProviderModelChange(provider, resolvedModel); setIsMenuOpen(false); @@ -126,7 +103,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { props.triggerClassName, )} disabled={props.disabled} - title={props.disabled ? props.disabledReason : undefined} /> } > @@ -152,7 +128,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {props.lockedProvider !== null ? ( handleModelChange(props.lockedProvider!, value)} > {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( @@ -168,7 +144,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : ( <> - {availableProviderOptions.map((option) => { + {AVAILABLE_PROVIDER_OPTIONS.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; const liveProvider = props.providers ? getProviderSnapshot(props.providers, option.value) @@ -210,15 +186,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { handleModelChange(option.value, value)} > {props.modelOptionsByProvider[option.value].map((modelOption) => ( @@ -236,8 +204,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ); })} - {unavailableProviderOptions.length > 0 && } - {unavailableProviderOptions.map((option) => { + {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } + {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; return ( @@ -252,7 +220,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ); })} - {unavailableProviderOptions.length === 0 && } + {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } {COMING_SOON_PROVIDER_OPTIONS.map((option) => { const OptionIcon = option.icon; return ( diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 68267509e76..77cb1b9a269 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -139,7 +139,9 @@ function getSelectedTraits( // Thinking toggle (only for models that support it) const thinkingEnabled = caps.supportsThinkingToggle - ? ((modelOptions as { thinking?: boolean } | undefined)?.thinking ?? true) + ? modelOptions && "thinking" in modelOptions + ? modelOptions.thinking === true + : null : null; // Fast mode From d4b40dbd91d2aabf61937c325ca183f819eb8e92 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 14:40:13 -0700 Subject: [PATCH 70/82] fixx --- apps/web/src/session-logic.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 96ed3d5b992..d3338ecc081 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,3 +1,5 @@ +import * as Option from "effect/Option"; +import * as Arr from "effect/Array"; import { ApprovalRequestId, isToolLifecycleItemType, @@ -30,7 +32,7 @@ export const PROVIDER_OPTIONS: Array<{ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, { value: "opencode", label: "OpenCode", available: true }, - { value: "cursor", label: "Cursor", available: false }, + { value: "cursor", label: "Cursor", available: true }, ]; export interface WorkLogEntry { @@ -353,12 +355,11 @@ export function deriveActivePlanState( const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); // Prefer plan from the current turn; fall back to the most recent plan from any turn // so that TodoWrite tasks persist across follow-up messages. - const latest = - (latestTurnId - ? allPlanActivities.filter((activity) => activity.turnId === latestTurnId).at(-1) - : undefined) ?? - allPlanActivities.at(-1) ?? - null; + const latest = Option.getOrNull( + latestTurnId + ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) + : Arr.last(allPlanActivities), + ); if (!latest) { return null; } From 849e3dce7f240dc2b4a824823502aac993591369 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 14:47:52 -0700 Subject: [PATCH 71/82] fix option logic --- apps/web/src/session-logic.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d3338ecc081..477bc57bea0 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -355,11 +355,12 @@ export function deriveActivePlanState( const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); // Prefer plan from the current turn; fall back to the most recent plan from any turn // so that TodoWrite tasks persist across follow-up messages. - const latest = Option.getOrNull( - latestTurnId + const latest = Option.firstSomeOf([ + ...(latestTurnId ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) - : Arr.last(allPlanActivities), - ); + : Option.none()), + Arr.last(allPlanActivities), + ]).pipe(Option.getOrNull); if (!latest) { return null; } From ed2a46ffa87f3dadcccdee89c715413bd0edd8f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 15:05:09 -0700 Subject: [PATCH 72/82] Skip redundant ACP session config writes - Apply model and mode configuration during session start - Avoid repeating no-op config writes on subsequent turns --- .../src/provider/Layers/CursorAdapter.test.ts | 88 ++++++++++++++++-- .../src/provider/Layers/CursorAdapter.ts | 93 ++++++++++++++----- .../provider/acp/AcpJsonRpcConnection.test.ts | 35 +++++++ .../src/provider/acp/AcpSessionRuntime.ts | 81 +++++++++++----- 4 files changed, 248 insertions(+), 49 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index bdb47a229c5..e6bbd7569a4 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -316,12 +316,14 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { yield* adapter.stopSession(threadId); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const modeRequest = requests.find( - (entry) => - entry.method === "session/set_mode" || - (entry.method === "session/set_config_option" && - (entry.params as Record | undefined)?.configId === "mode"), - ); + const modeRequest = requests + .toReversed() + .find( + (entry) => + entry.method === "session/set_mode" || + (entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "mode"), + ); assert.isDefined(modeRequest); assert.equal( (modeRequest?.params as Record | undefined)?.sessionId, @@ -337,6 +339,80 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); + it.effect( + "applies initial model and mode configuration during startSession and skips repeating it on first send", + () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-initial-config-probe"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* serverSettings.updateSettings({ + providers: { cursor: { binaryPath: wrapperPath } }, + }); + + const modelSelection = { + provider: "cursor" as const, + model: "gpt-5.4", + options: { + reasoning: "xhigh" as const, + contextWindow: "1m", + fastMode: true, + }, + }; + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection, + }); + + yield* Effect.promise(() => waitForFileContent(requestLogPath)); + + const requestsAfterStart = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const configIdsAfterStart = requestsAfterStart.flatMap((entry) => + entry.method === "session/set_config_option" && + typeof (entry.params as Record | undefined)?.configId === "string" + ? [String((entry.params as Record).configId)] + : [], + ); + assert.deepStrictEqual(configIdsAfterStart, [ + "model", + "reasoning", + "context", + "fast", + "mode", + ]); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + modelSelection, + interactionMode: "default", + }); + yield* adapter.stopSession(threadId); + + const finalRequests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const finalConfigIds = finalRequests.flatMap((entry) => + entry.method === "session/set_config_option" && + typeof (entry.params as Record | undefined)?.configId === "string" + ? [String((entry.params as Record).configId)] + : [], + ); + assert.deepStrictEqual(finalConfigIds, ["model", "reasoning", "context", "fast", "mode"]); + assert.equal(finalRequests.filter((entry) => entry.method === "session/prompt").length, 1); + }), + ); + it.effect( "streams ACP tool calls and approvals on the active turn in approval-required mode", () => diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index c71c0b39c57..5ee423282ce 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -7,6 +7,7 @@ import * as nodePath from "node:path"; import { ApprovalRequestId, + type CursorModelOptions, EventId, type ProviderApprovalDecision, type ProviderInteractionMode, @@ -214,6 +215,55 @@ function resolveRequestedModeId(input: { ); } +function applyRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntimeShape; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly modelSelection: + | { + readonly model: string; + readonly options?: CursorModelOptions | null | undefined; + } + | undefined; + readonly mapError: (context: { + readonly cause: import("effect-acp/errors").AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.modelSelection) { + yield* applyCursorAcpModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + modelOptions: input.modelSelection.options, + mapError: ({ cause }) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + }); + } + + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return; + } + + yield* input.runtime.setMode(requestedModeId).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_mode", + }), + ), + ); + }); +} + function selectAutoApprovedPermissionOption( request: EffectAcpSchema.RequestPermissionRequest, ): string | undefined { @@ -604,6 +654,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ), ); + yield* applyRequestedSessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + modelSelection: cursorModelSelection, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + const now = yield* nowIso; const session: ProviderSession = { provider: PROVIDER, @@ -759,12 +818,19 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; const model = turnModelSelection?.model ?? ctx.session.model; const resolvedModel = resolveCursorAcpBaseModelId(model); - yield* applyCursorAcpModelSelection({ + yield* applyRequestedSessionConfiguration({ runtime: ctx.acp, - model, - modelOptions: turnModelSelection?.options, - mapError: ({ cause }) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + modelSelection: + model === undefined + ? undefined + : { + model, + options: turnModelSelection?.options, + }, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), }); ctx.activeTurnId = turnId; ctx.lastPlanFingerprint = undefined; @@ -774,23 +840,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { updatedAt: yield* nowIso, }; - const requestedModeId = resolveRequestedModeId({ - interactionMode: input.interactionMode, - runtimeMode: ctx.session.runtimeMode, - modeState: yield* ctx.acp.getModeState, - }); - if (requestedModeId) { - yield* Effect.ignore( - ctx.acp - .setMode(requestedModeId) - .pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", error), - ), - ), - ); - } - yield* offerRuntimeEvent({ type: "turn.started", ...(yield* makeEventStamp()), diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index d74253b4986..ec2925ebaa9 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -261,6 +261,41 @@ describe("AcpSessionRuntime", () => { ); }); + it.effect("skips no-op session config writes when the requested value is already active", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + yield* runtime.setConfigOption("model", "default"); + yield* runtime.setMode("ask"); + + expect( + requestEvents.some( + (event) => event.method === "session/set_config_option" && event.status === "started", + ), + ).toBe(false); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: bunExe, + args: [mockAgentPath], + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => { const protocolEvents: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 15ffad98e05..3c53d6d1b1a 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -309,32 +309,47 @@ const makeAcpSessionRuntime = ( | EffectAcpSchema.ResumeSessionResponse, ): Effect.Effect => Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(response)); + const updateCurrentModeId = (modeId: string): Effect.Effect => + Ref.update(modeStateRef, (current) => + current ? { ...current, currentModeId: modeId } : current, + ); + const setConfigOption = ( configId: string, value: string | boolean, ): Effect.Effect => validateConfigOptionValue(configId, value).pipe( Effect.flatMap(() => getStartedState), - Effect.flatMap((started) => { - const requestPayload = - typeof value === "boolean" - ? ({ - sessionId: started.sessionId, - configId, - type: "boolean", - value, - } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) - : ({ - sessionId: started.sessionId, - configId, - value: String(value), - } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); - return runLoggedRequest( - "session/set_config_option", - requestPayload, - acp.agent.setSessionConfigOption(requestPayload), - ).pipe(Effect.tap((response) => updateConfigOptions(response))); - }), + Effect.flatMap((started) => + Ref.get(configOptionsRef).pipe( + Effect.flatMap((configOptions) => { + const existing = findSessionConfigOption(configOptions, configId); + if (existing && configOptionCurrentValueMatches(existing, value)) { + return Effect.succeed({ + configOptions, + } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); + } + const requestPayload = + typeof value === "boolean" + ? ({ + sessionId: started.sessionId, + configId, + type: "boolean", + value, + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) + : ({ + sessionId: started.sessionId, + configId, + value: String(value), + } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); + return runLoggedRequest( + "session/set_config_option", + requestPayload, + acp.agent.setSessionConfigOption(requestPayload), + ).pipe(Effect.tap((response) => updateConfigOptions(response))); + }), + ), + ), ); const startOnce = Effect.gen(function* () { @@ -501,7 +516,17 @@ const makeAcpSessionRuntime = ( Effect.flatMap((started) => acp.agent.cancel({ sessionId: started.sessionId })), ), setMode: (modeId) => - getStartedState.pipe(Effect.flatMap(() => setConfigOption("mode", modeId))), + Ref.get(modeStateRef).pipe( + Effect.flatMap((modeState) => { + if (modeState?.currentModeId === modeId) { + return Effect.succeed({} satisfies EffectAcpSchema.SetSessionModeResponse); + } + return setConfigOption("mode", modeId).pipe( + Effect.tap(() => updateCurrentModeId(modeId)), + Effect.as({} satisfies EffectAcpSchema.SetSessionModeResponse), + ); + }), + ), setConfigOption, setModel: (model) => getStartedState.pipe( @@ -524,6 +549,20 @@ function sessionConfigOptionsFromSetup( return response?.configOptions ?? []; } +function configOptionCurrentValueMatches( + configOption: EffectAcpSchema.SessionConfigOption, + value: string | boolean, +): boolean { + const currentValue = configOption.currentValue; + if (configOption.type === "boolean") { + return currentValue === value; + } + if (typeof currentValue !== "string") { + return false; + } + return currentValue.trim() === String(value).trim(); +} + const handleSessionUpdate = ({ queue, modeStateRef, From 10c1fafca5ce86acf37fc09b246d340fb62a318f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 15:16:40 -0700 Subject: [PATCH 73/82] Expose ACP event stream via getter - Lazily create the event stream from the shared queue - Co-authored-by: codex --- apps/server/src/provider/acp/AcpSessionRuntime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 3c53d6d1b1a..d32e70cb4ce 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -77,7 +77,7 @@ export interface AcpSessionRuntimeShape { readonly handleExtRequest: EffectAcpClient.AcpClientShape["handleExtRequest"]; readonly handleExtNotification: EffectAcpClient.AcpClientShape["handleExtNotification"]; readonly start: () => Effect.Effect; - readonly events: Stream.Stream; + readonly getEvents: () => Stream.Stream; readonly getModeState: Effect.Effect; readonly getConfigOptions: Effect.Effect>; readonly prompt: ( @@ -482,7 +482,7 @@ const makeAcpSessionRuntime = ( handleExtRequest: acp.handleExtRequest, handleExtNotification: acp.handleExtNotification, start: () => start, - events: Stream.fromQueue(eventQueue), + getEvents: () => Stream.fromQueue(eventQueue), getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), prompt: (payload) => From 47bd4befaa6cc20b19b34cd957425447c502583f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 15:19:00 -0700 Subject: [PATCH 74/82] Fallback to current model option in Cursor probes - annotate ACP probe spans with the active option id when the probe option is missing - preserves model capability discovery metadata --- apps/server/src/provider/Layers/CursorProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 5aa59f8860a..2f1a1bef117 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -516,7 +516,7 @@ export const discoverCursorModelCapabilitiesViaAcp = ( yield* Effect.annotateCurrentSpan({ "cursor.acp.model.value": modelSlug, "cursor.acp.model.currentValue": probeCurrentModelValue, - "cursor.acp.config_option_id": probeModelOption?.id, + "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id, }); const nextConfigOptions = probeCurrentModelValue === modelSlug From 239596702d7cb9d319766b55ab86d32076068768 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 15:24:27 -0700 Subject: [PATCH 75/82] Use runtime event accessor consistently - Switch CursorAdapter to `acp.getEvents()` - Update ACP session tests to read from the accessor --- apps/server/src/provider/Layers/CursorAdapter.ts | 2 +- apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 5ee423282ce..b09e0356bfb 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -694,7 +694,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }; const nf = yield* Stream.runDrain( - Stream.mapEffect(acp.events, (event) => + Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { switch (event._tag) { case "ModeChanged": diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index ec2925ebaa9..4820d5c2e58 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -72,7 +72,7 @@ describe("AcpSessionRuntime", () => { }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.events, 4))); + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); expect(notes).toHaveLength(4); expect(notes.map((note) => note._tag)).toEqual([ "PlanUpdated", @@ -120,7 +120,7 @@ describe("AcpSessionRuntime", () => { }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.events, 7))); + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 7))); expect(notes.map((note) => note._tag)).toEqual([ "AssistantItemStarted", "ContentDelta", @@ -181,7 +181,7 @@ describe("AcpSessionRuntime", () => { }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.events, 1))); + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1))); expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]); const toolCall = notes[0]; expect(toolCall?._tag).toBe("ToolCallUpdated"); From de2c42401ed2f4505845c983b2c587451ba95740 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 15:27:30 -0700 Subject: [PATCH 76/82] Handle provider request errors by detail text - Match unknown approval and user-input errors from any provider adapter error - Surface provider error detail instead of the generic message --- .../src/orchestration/Layers/ProviderCommandReactor.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 8b23d87851d..0bd6b196aee 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -98,7 +98,7 @@ function findProviderAdapterRequestError( function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = findProviderAdapterRequestError(cause); - if (Schema.is(ProviderAdapterRequestError)(error)) { + if (error) { const detail = error.detail.toLowerCase(); return ( detail.includes("unknown pending approval request") || @@ -114,7 +114,7 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = findProviderAdapterRequestError(cause); - if (Schema.is(ProviderAdapterRequestError)(error)) { + if (error) { return error.detail.toLowerCase().includes("unknown pending user-input request"); } return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request"); @@ -211,7 +211,7 @@ const make = Effect.gen(function* () { ? failReason.error : undefined; if (providerError) { - return providerError.message; + return providerError.detail; } return Cause.pretty(cause); }; From 7555ae6d85e170a57522f78583c685c7fc5f0cd3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 15:29:51 -0700 Subject: [PATCH 77/82] kewl --- apps/web/src/session-logic.test.ts | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 269c00604cd..bee0fd0029f 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1517,32 +1517,3 @@ describe("deriveActiveWorkStartedAt", () => { ).toBe("2026-02-27T21:11:00.000Z"); }); }); - -describe("PROVIDER_OPTIONS", () => { - it("advertises available providers while keeping Cursor as a placeholder", () => { - const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); - const opencode = PROVIDER_OPTIONS.find((option) => option.value === "opencode"); - const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); - expect(PROVIDER_OPTIONS).toEqual([ - { value: "codex", label: "Codex", available: true }, - { value: "claudeAgent", label: "Claude", available: true }, - { value: "opencode", label: "OpenCode", available: true }, - { value: "cursor", label: "Cursor", available: false }, - ]); - expect(claude).toEqual({ - value: "claudeAgent", - label: "Claude", - available: true, - }); - expect(opencode).toEqual({ - value: "opencode", - label: "OpenCode", - available: true, - }); - expect(cursor).toEqual({ - value: "cursor", - label: "Cursor", - available: false, - }); - }); -}); From f91a9651b901f16ebea71136fd3552496ba5fbfa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 15:33:30 -0700 Subject: [PATCH 78/82] me --- .../chat/composerProviderRegistry.test.tsx | 234 ------------------ 1 file changed, 234 deletions(-) diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index fa08b0fec4f..c4dd2cbb6ee 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -27,87 +27,6 @@ const CODEX_MODELS: ReadonlyArray = [ }, ]; -const CURSOR_MODELS: ReadonlyArray = [ - { - slug: "auto", - name: "Auto", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "composer-2", - name: "Composer 2", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex", - name: "Codex 5.3", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra high" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra high" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "272k", label: "272k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, -]; - const CLAUDE_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-6", @@ -394,159 +313,6 @@ describe("getComposerProviderState", () => { }); }); - it("returns minimal state for Cursor without trait controls", () => { - const state = getComposerProviderState({ - provider: "cursor", - model: "auto", - models: CURSOR_MODELS, - prompt: "", - modelOptions: undefined, - }); - - expect(state).toEqual({ - provider: "cursor", - promptEffort: null, - modelOptionsForDispatch: undefined, - }); - }); - - it("dispatches Cursor fast traits separately from the family model key", () => { - const state = getComposerProviderState({ - provider: "cursor", - model: "composer-2", - models: CURSOR_MODELS, - prompt: "", - modelOptions: { - cursor: { fastMode: true }, - }, - }); - - expect(state).toEqual({ - provider: "cursor", - promptEffort: null, - modelOptionsForDispatch: { fastMode: true }, - }); - }); - - it("resolves Cursor reasoning effort from server-driven capabilities", () => { - const state = getComposerProviderState({ - provider: "cursor", - model: "gpt-5.3-codex", - models: CURSOR_MODELS, - prompt: "", - modelOptions: { - cursor: { reasoning: "high" }, - }, - }); - - expect(state).toEqual({ - provider: "cursor", - promptEffort: "high", - modelOptionsForDispatch: { reasoning: "high" }, - }); - }); - - it("preserves default Cursor reasoning in dispatch options so prior overrides can be cleared", () => { - const state = getComposerProviderState({ - provider: "cursor", - model: "gpt-5.3-codex", - models: CURSOR_MODELS, - prompt: "", - modelOptions: { - cursor: { reasoning: "medium" }, - }, - }); - - expect(state).toEqual({ - provider: "cursor", - promptEffort: "medium", - modelOptionsForDispatch: { reasoning: "medium" }, - }); - }); - - it("preserves Cursor context window in dispatch options", () => { - const state = getComposerProviderState({ - provider: "cursor", - model: "gpt-5.4", - models: CURSOR_MODELS, - prompt: "", - modelOptions: { - cursor: { - contextWindow: "1m", - }, - }, - }); - - expect(state).toEqual({ - provider: "cursor", - promptEffort: "medium", - modelOptionsForDispatch: { - contextWindow: "1m", - reasoning: "medium", - }, - }); - }); - - it("preserves explicit default Cursor reasoning so deepMerge can clear a prior non-default", () => { - const state = getComposerProviderState({ - provider: "cursor", - model: "gpt-5.4", - models: CURSOR_MODELS, - prompt: "", - modelOptions: { - cursor: { reasoning: "medium" }, - }, - }); - - expect(state.modelOptionsForDispatch).toHaveProperty("reasoning", "medium"); - }); - - it("preserves explicit Cursor fastMode: false so deepMerge can overwrite a prior true", () => { - const state = getComposerProviderState({ - provider: "cursor", - model: "composer-2", - models: CURSOR_MODELS, - prompt: "", - modelOptions: { - cursor: { fastMode: false }, - }, - }); - - expect(state.modelOptionsForDispatch).toHaveProperty("fastMode", false); - }); - - it("preserves explicit Cursor thinking: true so deepMerge can overwrite a prior false", () => { - const state = getComposerProviderState({ - provider: "cursor", - model: "claude-opus-4-6", - models: CURSOR_MODELS, - prompt: "", - modelOptions: { - cursor: { thinking: true }, - }, - }); - - expect(state.modelOptionsForDispatch).toHaveProperty("thinking", true); - }); - - it("preserves Cursor max effort for Claude-family models", () => { - const state = getComposerProviderState({ - provider: "cursor", - model: "claude-opus-4-6", - models: CURSOR_MODELS, - prompt: "", - modelOptions: { - cursor: { reasoning: "max" }, - }, - }); - - expect(state).toEqual({ - provider: "cursor", - promptEffort: "max", - modelOptionsForDispatch: { reasoning: "max" }, - }); - }); - it("preserves Claude default effort explicitly in dispatch options", () => { const state = getComposerProviderState({ provider: "claudeAgent", From 1753bc686993fbb7f8eea2bbeec7406789e0ec15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 15:41:36 -0700 Subject: [PATCH 79/82] retry capability probe --- apps/server/src/provider/Layers/CursorProvider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 2f1a1bef117..70d5656b3ec 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -531,6 +531,7 @@ export const discoverCursorModelCapabilitiesViaAcp = ( }), ).pipe( Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), + Effect.retry({ times: 3 }), Effect.withSpan("cursor-acp-model-capability-probe"), Effect.catchCause((cause) => Effect.logWarning("Cursor ACP capability probe failed", { From 0c46385beb7854b3a7128ff71ac08b47646581a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 16:02:38 -0700 Subject: [PATCH 80/82] Default Cursor off and show Early Access badge in settings - Set cursor.enabled default to false in shared settings schema - Render optional provider badge on install cards (Cursor: Early Access) - Align server tests with settings-based Cursor disable and drop stale set_config assertion --- .../git/Layers/CursorTextGeneration.test.ts | 8 -- .../provider/Layers/ProviderRegistry.test.ts | 129 +++++++++--------- .../components/settings/SettingsPanels.tsx | 9 ++ packages/contracts/src/settings.ts | 2 +- 4 files changed, 78 insertions(+), 70 deletions(-) diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts index 2dc819bdfcb..e7bce113474 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts @@ -161,14 +161,6 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { parameterizedModelPicker: true, }, }); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "mode" && - request.params?.value === "ask", - ), - ).toBe(true); expect( requests.some( (request) => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index e857d2b0be8..14583e68426 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -824,68 +824,75 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ); - it.effect("omits cursor entirely when the runtime flag is disabled", () => - Effect.gen(function* () { - const previous = process.env.T3CODE_CURSOR_ENABLED; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env.T3CODE_CURSOR_ENABLED; - } else { - process.env.T3CODE_CURSOR_ENABLED = previous; - } - }), - ); - delete process.env.T3CODE_CURSOR_ENABLED; - - let cursorSpawned = false; - const serverSettings = yield* makeMutableServerSettingsService(); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const providerRegistryLayer = ProviderRegistryLive.pipe( - Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge( - mockCommandSpawnerLayer((command, args) => { - if (command === "agent") { - cursorSpawned = true; - } - const joined = args.join(" "); - if (joined === "--version") { - return { stdout: `${command} 1.0.0\n`, stderr: "", code: 0 }; - } - if (joined === "login status") { - return { stdout: "Logged in\n", stderr: "", code: 0 }; - } - if (joined === "auth status") { - return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; - } - throw new Error(`Unexpected args: ${command} ${joined}`); - }), - ), - ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), - providerRegistryLayer, - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry; - const providers = yield* registry.getProviders; - - assert.deepStrictEqual( - providers.map((provider) => provider.provider), - ["codex", "claudeAgent", "opencode"], + it.effect( + "keeps cursor disabled and skips probing when the provider setting is disabled", + () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService( + Schema.decodeSync(ServerSettings)( + deepMerge(DEFAULT_SERVER_SETTINGS, { + providers: { + cursor: { + enabled: false, + }, + }, + }), + ), ); - assert.strictEqual(cursorSpawned, false); - }).pipe(Effect.provide(runtimeServices)); - }), + let cursorSpawned = false; + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + if (command === "agent") { + cursorSpawned = true; + } + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: `${command} 1.0.0\n`, stderr: "", code: 0 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + if (joined === "auth status") { + return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${command} ${joined}`); + }), + ), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const providers = yield* registry.getProviders; + const cursorProvider = providers.find((provider) => provider.provider === "cursor"); + + assert.deepStrictEqual( + providers.map((provider) => provider.provider), + ["codex", "claudeAgent", "opencode", "cursor"], + ); + assert.strictEqual(cursorProvider?.enabled, false); + assert.strictEqual(cursorProvider?.status, "disabled"); + assert.strictEqual( + cursorProvider?.message, + "Cursor is disabled in T3 Code settings.", + ); + assert.strictEqual(cursorSpawned, false); + }).pipe(Effect.provide(runtimeServices)); + }), ); it.effect("skips codex probes entirely when the provider is disabled", () => diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a790636a799..230b0a9965d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -56,6 +56,7 @@ import { } from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; +import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; @@ -103,6 +104,7 @@ const TIMESTAMP_FORMAT_LABELS = { type InstallProviderSettings = { provider: ProviderKind; title: string; + badgeLabel?: string; binaryPlaceholder: string; binaryDescription: ReactNode; serverUrlPlaceholder?: string; @@ -133,6 +135,7 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ { provider: "cursor", title: "Cursor", + badgeLabel: "Early Access", binaryPlaceholder: "Cursor agent binary path", binaryDescription: "Path to the Cursor agent binary", }, @@ -773,6 +776,7 @@ export function GeneralSettingsPanel() { return { provider: providerSettings.provider, title: providerSettings.title, + badgeLabel: providerSettings.badgeLabel, binaryPlaceholder: providerSettings.binaryPlaceholder, binaryDescription: providerSettings.binaryDescription, serverUrlPlaceholder: providerSettings.serverUrlPlaceholder, @@ -1170,6 +1174,11 @@ export function GeneralSettingsPanel() { className={cn("size-2 shrink-0 rounded-full", providerCard.statusStyle.dot)} />

{providerDisplayName}

+ {providerCard.badgeLabel ? ( + + {providerCard.badgeLabel} + + ) : null} {providerCard.versionLabel ? ( {providerCard.versionLabel} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f71efb9ddc0..6c45dde5300 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -92,7 +92,7 @@ export const ClaudeSettings = Schema.Struct({ export type ClaudeSettings = typeof ClaudeSettings.Type; export const CursorSettings = Schema.Struct({ - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), binaryPath: makeBinaryPathSetting("agent"), apiEndpoint: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), From b2f3a10385dd5f27b21403c2a3461684cec5a7de Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 16:07:58 -0700 Subject: [PATCH 81/82] Honor explicit turn model when session model switching is unsupported - Only reuse the active session model if the turn omits modelSelection - Add regression test for restart with an explicit model override --- .../Layers/ProviderCommandReactor.test.ts | 64 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 2 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index dfdfab926f8..40b974d1eee 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -801,6 +801,70 @@ describe("ProviderCommandReactor", () => { }); }); + it("keeps an explicit model override when restarting an unsupported model-switch session", async () => { + const harness = await createHarness({ sessionModelSwitch: "unsupported" }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-unsupported-restart-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-restart-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-unsupported-restart-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-restart-2"), + role: "user", + text: "second", + attachments: [], + }, + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + }); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + }); + }); + it("rejects a first turn when requested provider conflicts with the thread model", async () => { const harness = await createHarness({ threadModelSelection: { provider: "codex", model: "gpt-5-codex" }, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 0bd6b196aee..8b3321ec3ea 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -427,7 +427,7 @@ const make = Effect.gen(function* () { const requestedModelSelection = input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; const modelForTurn = - sessionModelSwitch === "unsupported" + sessionModelSwitch === "unsupported" && input.modelSelection === undefined ? activeSession?.model !== undefined ? { ...requestedModelSelection, From aa696b5d2a0ec36b91ed7a8f4874d2f9c34aafc7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 16:09:14 -0700 Subject: [PATCH 82/82] Remove test for model override on unsupported session restart - Drop ProviderCommandReactor case covering explicit model override when restarting with sessionModelSwitch unsupported --- .../Layers/ProviderCommandReactor.test.ts | 64 ------------------- 1 file changed, 64 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 40b974d1eee..dfdfab926f8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -801,70 +801,6 @@ describe("ProviderCommandReactor", () => { }); }); - it("keeps an explicit model override when restarting an unsupported model-switch session", async () => { - const harness = await createHarness({ sessionModelSwitch: "unsupported" }); - const now = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-unsupported-restart-1"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-unsupported-restart-1"), - role: "user", - text: "first", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.startSession.mock.calls.length === 1); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-unsupported-restart-2"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-unsupported-restart-2"), - role: "user", - text: "second", - attachments: [], - }, - modelSelection: { - provider: "codex", - model: "gpt-5.4", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.startSession.mock.calls.length === 2); - await waitFor(() => harness.sendTurn.mock.calls.length === 2); - - expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ - threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.4", - }, - }); - expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5.4", - }, - }); - }); - it("rejects a first turn when requested provider conflicts with the thread model", async () => { const harness = await createHarness({ threadModelSelection: { provider: "codex", model: "gpt-5-codex" },